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}