Skip to main content

haste_subscription/
lib.rs

1//! The single source of truth for what each [`SubscriptionTier`] allows.
2//!
3//! Everything a tier permits lives in the [`TIERS`] table below: the request
4//! budget the rate limiter enforces, the resource caps the tenant tier limits
5//! middleware enforces, whether a tenant may set its own name and logo, and the
6//! commercial facts (price, SLA, support response) the pricing page publishes.
7//!
8//! Nothing else in the workspace should hardcode a per-tier number. The server's
9//! `subscription_limits` modules read this table, and
10//! `cargo run subscription export` writes it to the website as JSON so the
11//! published prices cannot drift from the enforced limits.
12
13pub mod export;
14
15use haste_fhir_model::r4::generated::resources::ResourceType;
16use haste_jwt::claims::SubscriptionTier;
17use serde::Serialize;
18
19/// How many resources of one type a tenant may store.
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum ResourceLimit {
23    /// At most this many. `0` denies the resource type outright.
24    Count(u64),
25    /// No cap. Usage above what the plan includes is metered, not refused.
26    Unlimited,
27}
28
29impl ResourceLimit {
30    /// Whether storing one more resource would exceed this limit, given how
31    /// many the tenant already has.
32    pub fn is_exceeded_at(&self, current_total: u64) -> bool {
33        match self {
34            ResourceLimit::Count(limit) => current_total >= *limit,
35            ResourceLimit::Unlimited => false,
36        }
37    }
38}
39
40/// What one request costs against a tier's request budget.
41///
42/// Uniform across tiers — a tier buys a larger budget, not cheaper operations —
43/// so the pricing page can state the costs once.
44#[derive(Clone, Copy, Debug, Serialize)]
45pub struct OperationPoints {
46    pub read: u32,
47    pub write: u32,
48    pub search: u32,
49    pub history: u32,
50    pub invocation: u32,
51}
52
53/// The cost of each kind of request, in budget points.
54///
55/// Weighted by what the operation actually costs this server, which is not what
56/// it costs a server built differently:
57///
58/// - A **read** is a primary-key lookup. It is the unit of cost, priced at 1.
59/// - A **write** is the expensive one. Storage is append-only, so a write adds a
60///   history row, then deletes and re-inserts that resource's index rows across
61///   the typed index tables — an Observation has 76 search parameters, a Patient
62///   46 — and every bit of it is WAL plus an fsync on the primary, which cannot
63///   be scaled out. Hence 25.
64/// - A **search** is a bounded indexed `SELECT` with joins. Real work, but it
65///   reads warm shared buffers and can move to a read replica, so 6 rather than
66///   the write's 25.
67/// - **History** walks the version chain: heavier than a read, far cheaper than
68///   a write.
69/// - An **invocation** is an operation whose cost varies by definition; 4 covers
70///   the dispatch, and whatever the operation itself does is billed by the reads
71///   and writes it performs.
72///
73/// Deliberately not modelled on servers that make search the most expensive
74/// operation. With Postgres-backed search the binding constraint is write
75/// throughput on the primary, not search CPU.
76pub const OPERATION_POINTS: OperationPoints = OperationPoints {
77    read: 1,
78    write: 25,
79    search: 6,
80    history: 8,
81    invocation: 4,
82};
83
84/// How long a [`RequestBudget`] lasts before it resets, in minutes.
85///
86/// Short, so a tenant that spends its budget on an import waits minutes for
87/// the next window rather than a day. The budgets are sized to the window: each
88/// fits [`LARGEST_SYNTHEA_BUNDLE_POINTS`], so no realistic single import is
89/// refused outright.
90pub const REQUEST_BUDGET_WINDOW_MINUTES: u64 = 15;
91
92/// What the largest patient bundle in the Synthea sample download costs as a
93/// transaction: 19,020 entries, every one a write. Every metered tier's budget
94/// is at least this, so a whole Synthea patient always fits in one window.
95pub const LARGEST_SYNTHEA_BUNDLE_POINTS: usize = 19_020 * OPERATION_POINTS.write as usize;
96
97/// A tier's request budget per [`REQUEST_BUDGET_WINDOW_MINUTES`], in
98/// [`OPERATION_POINTS`].
99#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
100#[serde(rename_all = "snake_case")]
101pub enum RequestBudget {
102    /// This many points per rate limit window.
103    Points(usize),
104    /// Not rate limited.
105    Unmetered,
106}
107
108impl RequestBudget {
109    /// The budget as a point count, for the rate limiter. [`Self::Unmetered`]
110    /// saturates rather than being special-cased at every call site.
111    pub fn as_points(&self) -> usize {
112        match self {
113            RequestBudget::Points(points) => *points,
114            RequestBudget::Unmetered => usize::MAX,
115        }
116    }
117}
118
119/// Everything one subscription tier allows, and how it is sold.
120#[derive(Clone, Debug, Serialize)]
121pub struct TierLimits {
122    /// The tier this describes. Serializes to its claim value (`"free"`, …),
123    /// which is how the website keys its cards.
124    pub tier: SubscriptionTier,
125
126    // ---- What the server enforces ----
127    /// Requests allowed per rate limit window. See [`OPERATION_POINTS`].
128    pub request_budget: RequestBudget,
129    /// Total current-version resources the tenant may store across all
130    /// projects. History does not count.
131    pub total_resources: ResourceLimit,
132    /// Projects the tenant may create, including the `system` project.
133    pub projects: ResourceLimit,
134    /// Custom search parameters the tenant may define.
135    pub search_parameters: ResourceLimit,
136    /// Custom operations the tenant may define.
137    pub operation_definitions: ResourceLimit,
138    /// FHIR Subscriptions the tenant may register.
139    pub subscriptions: ResourceLimit,
140    /// External identity providers the tenant may configure.
141    pub identity_providers: ResourceLimit,
142    /// Whether the tenant may set its own display name and logo.
143    pub tenant_customization: bool,
144
145    // ---- How the tier is sold ----
146    /// Name shown on the pricing page. Deliberately not the claim value: the
147    /// claim is an API contract, this is marketing copy.
148    pub display_name: &'static str,
149    /// Formatted price, e.g. `"$1,500"`.
150    pub price: &'static str,
151    /// Billing cadence, or `None` for the tiers that are not billed monthly.
152    pub cadence: Option<&'static str>,
153    /// Who the tier is for.
154    pub audience: &'static str,
155    /// Support response commitment.
156    pub support: &'static str,
157    /// Uptime commitment.
158    pub uptime_sla: &'static str,
159    /// Whether a signed BAA is available, which gates holding PHI.
160    pub baa_available: bool,
161    /// Whether signup can put a tenant straight onto this tier.
162    pub self_serve: bool,
163}
164
165/// Every tier, in the order the pricing page presents them.
166///
167/// Ordered cheapest-first by hosted commitment, with the self-hosted tier last
168/// because it is not a hosted plan at all.
169pub const TIERS: &[TierLimits] = &[
170    // The tier every hosted signup starts on: a sandbox big enough to evaluate
171    // the server and build a prototype, with the compute-intensive meta
172    // resources withheld.
173    TierLimits {
174        tier: SubscriptionTier::Free,
175        // Enough to evaluate the server against real data: the largest patient
176        // in the Synthea sample download costs 475,500 points, so any whole
177        // patient loads in one window, and the whole 114-bundle sample (about
178        // 2.5 million points) loads in about an hour. A budget that cannot
179        // absorb one realistic import is a budget that ends the evaluation.
180        request_budget: RequestBudget::Points(500_000),
181        total_resources: ResourceLimit::Count(250_000),
182        // Two: the `system` project, plus one to build in.
183        projects: ResourceLimit::Count(2),
184        search_parameters: ResourceLimit::Count(0),
185        operation_definitions: ResourceLimit::Count(0),
186        subscriptions: ResourceLimit::Count(0),
187        identity_providers: ResourceLimit::Count(0),
188        tenant_customization: false,
189
190        display_name: "Developer",
191        price: "$0",
192        cadence: Some("/month"),
193        audience: "Evaluating, prototyping, building a demo",
194        support: "Community",
195        uptime_sla: "None",
196        baa_available: false,
197        self_serve: true,
198    },
199    // The first tier that may hold real patient data, so everything withheld on
200    // Developer opens up and the compliance artifacts come with it.
201    TierLimits {
202        tier: SubscriptionTier::Professional,
203        request_budget: RequestBudget::Points(10_000_000),
204        total_resources: ResourceLimit::Unlimited,
205        projects: ResourceLimit::Unlimited,
206        search_parameters: ResourceLimit::Unlimited,
207        operation_definitions: ResourceLimit::Unlimited,
208        subscriptions: ResourceLimit::Unlimited,
209        identity_providers: ResourceLimit::Unlimited,
210        tenant_customization: true,
211
212        display_name: "Production",
213        price: "$1,500",
214        cadence: Some("/month"),
215        audience: "Startups carrying real patient data",
216        support: "1 business day",
217        uptime_sla: "99.9%",
218        baa_available: true,
219        self_serve: false,
220    },
221    // Same permissions as Production, bought with a larger budget and a tighter
222    // support commitment.
223    TierLimits {
224        tier: SubscriptionTier::Team,
225        request_budget: RequestBudget::Points(50_000_000),
226        total_resources: ResourceLimit::Unlimited,
227        projects: ResourceLimit::Unlimited,
228        search_parameters: ResourceLimit::Unlimited,
229        operation_definitions: ResourceLimit::Unlimited,
230        subscriptions: ResourceLimit::Unlimited,
231        identity_providers: ResourceLimit::Unlimited,
232        tenant_customization: true,
233
234        display_name: "Scale",
235        price: "From $6,000",
236        cadence: Some("/month"),
237        audience: "Platforms at population scale",
238        support: "4 business hours",
239        uptime_sla: "99.95%",
240        baa_available: true,
241        self_serve: false,
242    },
243    // What a self-hosted deployment runs as: nothing metered, because there is
244    // no one to meter it for. Also the tier for hosted customers on a
245    // single-tenant deployment.
246    TierLimits {
247        tier: SubscriptionTier::Unlimited,
248        request_budget: RequestBudget::Unmetered,
249        total_resources: ResourceLimit::Unlimited,
250        projects: ResourceLimit::Unlimited,
251        search_parameters: ResourceLimit::Unlimited,
252        operation_definitions: ResourceLimit::Unlimited,
253        subscriptions: ResourceLimit::Unlimited,
254        identity_providers: ResourceLimit::Unlimited,
255        tenant_customization: true,
256
257        display_name: "Self-Hosted",
258        price: "Free",
259        cadence: None,
260        audience: "Teams who want to own their infrastructure",
261        support: "Community",
262        uptime_sla: "You operate it",
263        baa_available: false,
264        self_serve: false,
265    },
266];
267
268/// The tier every hosted signup starts on.
269pub const DEFAULT_TIER: SubscriptionTier = SubscriptionTier::Free;
270
271/// The limits for a tier.
272///
273/// Infallible: [`TIERS`] covers every [`SubscriptionTier`] variant, and
274/// `tiers_are_exhaustive` holds that true.
275pub fn limits_for(tier: &SubscriptionTier) -> &'static TierLimits {
276    TIERS
277        .iter()
278        .find(|limits| &limits.tier == tier)
279        .expect("TIERS covers every SubscriptionTier variant")
280}
281
282/// The cap on one resource type for a tier, or [`ResourceLimit::Unlimited`] for
283/// the types a tier does not cap individually.
284///
285/// This is the per-type cap only. A tenant is also held to
286/// [`TierLimits::total_resources`] across every type.
287pub fn resource_limit_for(tier: &SubscriptionTier, resource_type: &ResourceType) -> ResourceLimit {
288    let limits = limits_for(tier);
289
290    match resource_type {
291        ResourceType::Project => limits.projects,
292        ResourceType::SearchParameter => limits.search_parameters,
293        ResourceType::OperationDefinition => limits.operation_definitions,
294        ResourceType::Subscription => limits.subscriptions,
295        ResourceType::IdentityProvider => limits.identity_providers,
296        _ => ResourceLimit::Unlimited,
297    }
298}
299
300/// Whether a tier may set a tenant display name and logo.
301pub fn allows_tenant_customization(tier: &SubscriptionTier) -> bool {
302    limits_for(tier).tenant_customization
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    /// `limits_for` panics on a tier missing from `TIERS`, so every variant must
310    /// be present. Add a variant to `SubscriptionTier` and this fails until the
311    /// tier is described here too.
312    #[test]
313    fn tiers_are_exhaustive() {
314        let all = [
315            SubscriptionTier::Free,
316            SubscriptionTier::Professional,
317            SubscriptionTier::Team,
318            SubscriptionTier::Unlimited,
319        ];
320
321        for tier in &all {
322            limits_for(tier);
323        }
324
325        assert_eq!(
326            TIERS.len(),
327            all.len(),
328            "TIERS has a duplicate or extra tier"
329        );
330    }
331
332    #[test]
333    fn free_tier_withholds_compute_intensive_resources() {
334        let free = limits_for(&SubscriptionTier::Free);
335
336        assert_eq!(free.search_parameters, ResourceLimit::Count(0));
337        assert_eq!(free.operation_definitions, ResourceLimit::Count(0));
338        assert_eq!(free.subscriptions, ResourceLimit::Count(0));
339        assert_eq!(free.identity_providers, ResourceLimit::Count(0));
340        assert!(!free.tenant_customization);
341    }
342
343    /// A paid tier must not be more restrictive than the free one, in either
344    /// budget or resource caps.
345    #[test]
346    fn paid_tiers_are_not_more_restrictive_than_free() {
347        let free = limits_for(&SubscriptionTier::Free);
348
349        for paid in TIERS
350            .iter()
351            .filter(|limits| limits.tier != SubscriptionTier::Free)
352        {
353            assert!(
354                paid.request_budget.as_points() >= free.request_budget.as_points(),
355                "{} has a smaller request budget than Developer",
356                paid.display_name
357            );
358            assert!(
359                !paid
360                    .total_resources
361                    .is_exceeded_at(match free.total_resources {
362                        ResourceLimit::Count(count) => count,
363                        ResourceLimit::Unlimited => u64::MAX,
364                    }),
365                "{} caps total resources below Developer",
366                paid.display_name
367            );
368            assert!(
369                paid.tenant_customization,
370                "{} cannot customize its tenant",
371                paid.display_name
372            );
373        }
374    }
375
376    /// The weights encode this server's cost shape: a write is the most
377    /// expensive operation because it fans out across the index tables and
378    /// fsyncs on the primary, and a search costs more than a read but well under
379    /// a write because it can be served from a replica. If these invert, the
380    /// rate limiter is no longer charging for what the server actually spends.
381    #[test]
382    fn point_costs_track_what_operations_cost_us() {
383        let p = OPERATION_POINTS;
384
385        assert!(
386            p.write > p.search,
387            "a write must cost more than a search: it is the operation that cannot scale out"
388        );
389        assert!(
390            p.search > p.read,
391            "a search must cost more than a primary-key read"
392        );
393        assert!(
394            p.history > p.read && p.history < p.write,
395            "history sits between a read and a write"
396        );
397        assert_eq!(p.read, 1, "a read is the unit of cost");
398    }
399
400    /// A transaction costing more than a whole window's budget is refused
401    /// however long the caller waits, so every metered tier must fit the
402    /// largest Synthea patient bundle in one window.
403    #[test]
404    fn every_metered_tier_fits_the_largest_synthea_bundle() {
405        for limits in TIERS {
406            assert!(
407                limits.request_budget.as_points() >= LARGEST_SYNTHEA_BUNDLE_POINTS,
408                "{} cannot submit the largest Synthea bundle ({LARGEST_SYNTHEA_BUNDLE_POINTS} points) in one window",
409                limits.display_name
410            );
411        }
412    }
413
414    /// The free tier has to absorb one realistic data import or it cannot be
415    /// evaluated, and its resource cap must hold more than one window of writes
416    /// can create, so the two limits do not contradict each other.
417    #[test]
418    fn free_tier_can_load_and_query_real_data() {
419        let free = limits_for(&SubscriptionTier::Free);
420        let budget = free.request_budget.as_points();
421
422        let ResourceLimit::Count(cap) = free.total_resources else {
423            panic!("the free tier is expected to cap total resources");
424        };
425        let writes_per_window = budget / OPERATION_POINTS.write as usize;
426        assert!(
427            (cap as usize) > writes_per_window,
428            "the resource cap ({cap}) is below what one window of writes can create ({writes_per_window})"
429        );
430    }
431
432    #[test]
433    fn unlimited_tier_is_unmetered() {
434        let unlimited = limits_for(&SubscriptionTier::Unlimited);
435
436        assert_eq!(unlimited.request_budget, RequestBudget::Unmetered);
437        assert_eq!(unlimited.request_budget.as_points(), usize::MAX);
438        assert_eq!(unlimited.total_resources, ResourceLimit::Unlimited);
439    }
440
441    #[test]
442    fn resource_limit_for_uncapped_type_is_unlimited() {
443        assert_eq!(
444            resource_limit_for(&SubscriptionTier::Free, &ResourceType::Patient),
445            ResourceLimit::Unlimited
446        );
447    }
448
449    #[test]
450    fn counts_are_exceeded_at_the_limit_not_past_it() {
451        let two = ResourceLimit::Count(2);
452
453        assert!(!two.is_exceeded_at(1));
454        assert!(two.is_exceeded_at(2));
455        // A zero count denies the first write.
456        assert!(ResourceLimit::Count(0).is_exceeded_at(0));
457        assert!(!ResourceLimit::Unlimited.is_exceeded_at(u64::MAX));
458    }
459
460    /// Only the tier signup assigns may be self-serve; the rest require a
461    /// deliberate upgrade.
462    #[test]
463    fn only_the_default_tier_is_self_serve() {
464        for limits in TIERS {
465            assert_eq!(
466                limits.self_serve,
467                limits.tier == DEFAULT_TIER,
468                "{} has the wrong self_serve flag",
469                limits.display_name
470            );
471        }
472    }
473
474    /// A tier that may hold PHI must offer a BAA.
475    #[test]
476    fn tiers_without_a_baa_are_the_unbilled_ones() {
477        for limits in TIERS {
478            if limits.baa_available {
479                assert!(
480                    limits.cadence.is_some(),
481                    "{} offers a BAA but is not billed",
482                    limits.display_name
483                );
484            }
485        }
486    }
487}