Skip to main content

monetize_product/
lib.rs

1//! **`Product` — the plugin that makes monetize speak one product's language.**
2//!
3//! monetize handles MANY products, and each product's plugin lives in this
4//! repository. So monetize is
5//! one service for gunnar.rs, holger.rs, njord, …, and each product is a crate under
6//! `products/<name>` implementing this trait. The product itself carries only the thin
7//! `monetize-embed` hook behind a cargo feature; it never learns what capacity costs.
8//!
9//! ```text
10//!   product box ──Usage (gRPC, the product's own API)──▶ Product plugin ──▶ monetize core
11//!   product box ◀──Entitlement (product's own API)──── Product plugin ◀── monetize core
12//! ```
13//!
14//! # Two directions, both facts
15//!
16//! * [`Product::read_usage`] — what a tenant is consuming, in the product's own units
17//!   (gunnar: pack bytes, read-cache bytes, LFS bytes, tombstoned bytes, open-store RAM;
18//!   holger: whatever holger meters). The plugin maps them to [`Usage`], a flat bag of
19//!   named meters, so core never has product-specific fields.
20//! * [`Product::push_entitlement`] — the verdict, written back over the product's own
21//!   control plane (gunnar: `Entitlement.Set` with `source = payment:<vendor>:<ref>`).
22//!   Signed by monetize's key; the product appends it to its attestation log.
23//!
24//! # A plugin never blocks the product
25//!
26//! Counting is done **inside the product, always, open source** — a self-hoster wants the
27//! numbers too. The plugin only *reads* them. If monetize is down the product keeps
28//! serving on its cached entitlement; the plugin's job on reconnect is to catch up, not
29//! to have been in the request path.
30
31use std::collections::BTreeMap;
32
33/// A tenant, as the product names it. gunnar: the `Namespace` name (`team/sub`).
34#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug, serde::Serialize, serde::Deserialize)]
35pub struct TenantId(pub String);
36
37/// **Whether the product will actually POLICE a cap on this meter.**
38///
39/// CPU and RAM exist as quota even where gunnar cannot cap them, and that gap is
40/// narrowed without redoing the engine.
41///
42/// An order may cap any declared meter. But a cap on a meter nothing checks is a
43/// promise nobody keeps, and the tenant cannot tell the difference from the
44/// invoice. So the product says, per meter, which kind of promise it is — and
45/// the console shows it beside the number rather than letting every cap look
46/// like a guarantee.
47///
48/// This is the smallest honest way to let CPU and RAM be sold: they ARE bought,
49/// with real money, and they DO change what the box can do — they are simply not
50/// something a git server polices per namespace. Saying so is better than either
51/// refusing to sell them or pretending they are enforced.
52#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
53pub enum Enforcement {
54    /// **The product refuses when over.** A cap here is a wall the tenant hits.
55    /// gunnar: `pack_bytes` (receive-pack refused before the bytes),
56    /// `cache_bytes` (the explode budget).
57    Enforced,
58    /// **Counted and reported, not policed.** The number is true and monetize
59    /// can bill on it or a human can act on it, but nothing refuses. A cap here
60    /// is a threshold, not a wall.
61    Measured,
62    /// **Neither counted nor policed BY THE PRODUCT — it is iron monetize
63    /// bought.** CPU and RAM: a bigger plan at the provider, real money, real
64    /// effect, and no per-namespace check anywhere in the product. The
65    /// provider's invoice is the enforcement.
66    Provisioned,
67}
68
69impl Enforcement {
70    /// One line for the console, beside the cap.
71    pub fn describe(self) -> &'static str {
72        match self {
73            Enforcement::Enforced => "the product refuses when over",
74            Enforcement::Measured => "counted and reported; nothing refuses",
75            Enforcement::Provisioned => "provisioned at the cloud provider; not policed by the product",
76        }
77    }
78
79    /// Is a cap on this meter a wall the tenant will actually hit?
80    pub fn is_a_wall(self) -> bool {
81        matches!(self, Enforcement::Enforced)
82    }
83}
84
85/// **What a cap on this meter is MADE OF**, for the meters where a cap is a
86/// promise of CAPACITY rather than a count — and therefore what UNIT the price
87/// list prices it in and what iron the transaction buys for it.
88///
89/// A plan that sells bytes it never buys is the one bug here that silently
90/// converts money into nothing. Since the plan catalogue went (2026-09-05), an
91/// ORDER names caps and nothing else, and this is the field that turns a cap
92/// into a purchase: `monetize::order::resources_for` translates the DELTA on a
93/// disk-backed meter into a `Resource::Disk`, and `monetize::PriceList` prices
94/// each backing's delta by its own unit.
95///
96/// It lives on the meter and not on the order because only the PRODUCT knows
97/// what its meter is made of — that `pack_bytes` is block storage and `pushes`
98/// is a count of events that costs nothing to promise.
99#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
100pub enum Backing {
101    /// **Bytes of block storage.** A delta here becomes a `Resource::Disk` of
102    /// `ceil(bytes / 2^30)` GiB, and is priced per GiB per month
103    /// (`disk_gib_month` on the price list).
104    DiskBytes,
105    /// **CPU, in millicores (1000 = one core).** Priced per core per month
106    /// (`cpu_core_month`). monetize does NOT translate millicores into a
107    /// provider's plan name (`2xCPU-4GB` is a name, and a table of names goes
108    /// stale every time a provider renames a size), so a delta here buys no
109    /// cloud resource of its own: the ceiling is the product's own measurement
110    /// (`Product::can_absorb`) and the box's declared limit
111    /// (`CloudProvider::ceilings`).
112    CpuMillicores,
113    /// **RAM, in bytes.** Priced per GiB per month (`ram_gib_month`); the same
114    /// ceiling rule as [`Backing::CpuMillicores`].
115    RamBytes,
116}
117
118impl Backing {
119    /// The price-list key this backing is priced under, and the unit it means:
120    /// `disk_gib_month` | `cpu_core_month` | `ram_gib_month`.
121    pub fn unit(self) -> &'static str {
122        match self {
123            Backing::DiskBytes => "disk_gib_month",
124            Backing::CpuMillicores => "cpu_core_month",
125            Backing::RamBytes => "ram_gib_month",
126        }
127    }
128
129    /// Every backing, in the order the price list is written in.
130    pub const ALL: [Backing; 3] = [Backing::DiskBytes, Backing::CpuMillicores, Backing::RamBytes];
131
132    /// The backing a price-list key names, if any.
133    pub fn from_unit(unit: &str) -> Option<Backing> {
134        Backing::ALL.into_iter().find(|b| b.unit() == unit)
135    }
136
137    /// **Price `delta` of this backing at `minor_per_unit_month`, for one
138    /// month**, in minor units. Integer arithmetic, rounded UP to the unit for
139    /// bytes (a customer who asks for one byte over a GiB is sold the next GiB,
140    /// which is what the provider sells us) and exact per millicore for CPU
141    /// (`minor × millicores / 1000`).
142    pub fn price_month(self, delta: u64, minor_per_unit_month: u64) -> u64 {
143        const GIB: u64 = 1 << 30;
144        match self {
145            Backing::DiskBytes | Backing::RamBytes => delta.div_ceil(GIB).saturating_mul(minor_per_unit_month),
146            Backing::CpuMillicores => u64::try_from(u128::from(delta) * u128::from(minor_per_unit_month) / 1000).unwrap_or(u64::MAX),
147        }
148    }
149}
150
151/// **WHERE a meter's number is even meaningful: per tenant, or per product.**
152///
153/// Disk is the whole game, and it is per tenant. CPU and RAM stay in the model,
154/// but for gunnar they are not interesting per user — they are interesting per
155/// product: a tenant does not own cores, the box does.
156///
157/// Orthogonal to [`Enforcement`], and the pair is what makes a fleet total
158/// honest. `Enforcement` says whether a cap is a WALL. Scope says whether adding
159/// one tenant's figure to another's produces anything at all:
160///
161/// * `pack_bytes` is per tenant, so nine tenants' pack bytes sum to the fleet's
162///   pack bytes;
163/// * `cpu_millicores` is per product, so nine tenants' CPU does not sum to
164///   anything — nobody measured a tenant's cores, and adding nine numbers that
165///   were never measured is arithmetic on nothing.
166///
167/// **It is the PRODUCT's call, not a global rule.** gunnar meters disk per tenant
168/// and CPU per product; another product may legitimately meter CPU per user, or
169/// disk only in total. [`Product::meters`] is already that seam, and nothing
170/// outside a product decides which of its meters are per-tenant. `monetize::fleet`
171/// sums only [`Scope::Tenant`], whatever a product declares.
172#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
173pub enum Scope {
174    /// **Measured, or sold, per TENANT.** A fleet figure is the sum over tenants.
175    /// The default, because it is what a meter usually is.
176    Tenant,
177    /// **Measured, or bought, for the WHOLE PRODUCT.** It appears ONCE, on the
178    /// product screen, and never in a per-tenant sum. A cap on it may still be
179    /// sold — CPU and RAM are bought with real money — it simply is not a
180    /// quantity any one tenant holds.
181    Product,
182}
183
184impl Scope {
185    /// May a figure for this meter be summed across tenants? Only [`Scope::Tenant`].
186    pub fn sums_across_tenants(self) -> bool {
187        matches!(self, Scope::Tenant)
188    }
189
190    /// `tenant` | `product` — the wire and JSON spelling, one writer.
191    pub fn name(self) -> &'static str {
192        match self {
193            Scope::Tenant => "tenant",
194            Scope::Product => "product",
195        }
196    }
197}
198
199/// **One meter a product declares**: its name, what it means, and whether a cap
200/// on it is policed. An order may only cap a declared meter, and the console
201/// shows [`Enforcement`] beside the number.
202#[derive(Clone, Copy, PartialEq, Eq, Debug)]
203pub struct Meter {
204    /// The wire name, and the key in [`EntitlementFact::caps`] and [`Usage::meters`].
205    pub name: &'static str,
206    /// One line, the plugin's own words.
207    pub meaning: &'static str,
208    pub enforcement: Enforcement,
209    /// **Does [`Product::read_usage`] carry a reading for this meter?**
210    ///
211    /// A meter exists to do one or both of two jobs: carry a usage READING, and
212    /// accept a CAP. Most do both. Two kinds do not, and conflating them was
213    /// caught by a test rather than by thinking:
214    ///
215    /// * `cpu_millicores`, `ram_bytes` — the product has no idea what was
216    ///   bought; monetize does, from the order. Reporting `0` would read as "you
217    ///   have none", which is worse than saying nothing.
218    /// * `concurrent_transfers` — a cap the engine enforces instantly, with no
219    ///   stored reading to bill from. The limit is real; the gauge does not exist.
220    ///
221    /// So a plugin's `read_usage` must report EXACTLY the meters with this set,
222    /// which is an equality a test can hold in both directions: an undeclared
223    /// reading is a leak, and a declared reading that stopped arriving is a
224    /// silently emptied bill.
225    pub reports_usage: bool,
226    /// **What a cap on this meter is made of**, or `None` when a cap here costs
227    /// nothing to grant (a count of pushes, a number of repositories the
228    /// existing box already holds). `Some` is what makes an order on this meter
229    /// a PURCHASE: the delta is priced by the backing's unit and, for disk,
230    /// bought as iron. See [`Backing`].
231    pub backing: Option<Backing>,
232    /// **Whether this meter's number is per tenant or per product.** See
233    /// [`Scope`]; it is what keeps a fleet total from adding up figures nobody
234    /// measured per tenant.
235    pub scope: Scope,
236}
237
238impl Meter {
239    /// A meter that both reports a reading and may be capped — the common case.
240    pub const fn new(name: &'static str, meaning: &'static str, enforcement: Enforcement) -> Meter {
241        Meter { name, meaning, enforcement, reports_usage: true, backing: None, scope: Scope::Tenant }
242    }
243
244    /// A meter that may be CAPPED but carries no reading. See
245    /// [`Meter::reports_usage`] for the two kinds and why `0` is not a truthful
246    /// substitute.
247    pub const fn cap_only(name: &'static str, meaning: &'static str, enforcement: Enforcement) -> Meter {
248        Meter { name, meaning, enforcement, reports_usage: false, backing: None, scope: Scope::Tenant }
249    }
250
251    /// The same meter, declaring what a cap on it is MADE OF — and so what an
252    /// order on it buys and what the price list prices it by. A
253    /// [`Enforcement::Provisioned`] meter without one is a cap monetize can
254    /// neither price nor buy; gunnar's own test holds its list to that.
255    pub const fn with_backing(self, backing: Backing) -> Meter {
256        Meter { backing: Some(backing), ..self }
257    }
258
259    /// The same meter, measured for the WHOLE PRODUCT rather than per tenant —
260    /// so a fleet total leaves it out instead of adding up figures nobody took.
261    /// See [`Scope`].
262    pub const fn per_product(self) -> Meter {
263        Meter { scope: Scope::Product, ..self }
264    }
265}
266
267/// **What a product says it could still serve, or why it could not say.**
268///
269/// The other half of the oversell number: `sum(what has been sold) − servable`.
270/// Without it monetize could sell ten tenants 10 GiB each on a box with 55 GiB
271/// servable and nothing would object until the sixth push.
272///
273/// The two arms are the distinction `BASE-MODEL.md` rule 3 turns on and the same
274/// one [`Product::can_absorb`] already makes: **an unanswered capacity question
275/// is a measurement that did not happen, not a full disk and not an empty one.**
276/// The live gunnar.rs appliance predates gunnar's `Admin.Capacity` RPC and
277/// answers [`Servable::Unmeasured`] today; a fleet total that turned that into a
278/// zero would report every deployment as catastrophically oversold, and one that
279/// turned it into infinity would report every deployment as fine. Neither is a
280/// measurement.
281/// **Can this product's store grow while it runs?** Carried on
282/// [`Servable::Measured`] beside the bytes, because the two answer different
283/// questions and an operator acts differently on each: a full box that can grow
284/// wants a disk; a full box that is SEALED wants a fresh install onto a larger
285/// set, and a disk bought for it is billed and invisible.
286///
287/// Each arm carries the PRODUCT's own remediation sentence (gunnar's
288/// `remediation()`), so the console renders what the product said rather than a
289/// paraphrase of it — `UI.md` screen 2's `⚠ cannot grow` badge holds exactly
290/// that paragraph.
291#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
292pub enum Growth {
293    /// Cannot gain capacity while it runs, and nothing on the machine can give
294    /// it any. `GROWTH_SEALED` on gunnar's wire.
295    Sealed(String),
296    /// The product will not grow the set itself, but an operator can, out of
297    /// band. `GROWTH_OPERATOR_ONLY`.
298    OperatorOnly(String),
299    /// The product could not tell. NOT a promise that it can grow.
300    Unknown(String),
301}
302
303impl Growth {
304    /// The wire word: `sealed` | `operator_only` | `unknown`.
305    pub fn name(&self) -> &'static str {
306        match self {
307            Growth::Sealed(_) => "sealed",
308            Growth::OperatorOnly(_) => "operator_only",
309            Growth::Unknown(_) => "unknown",
310        }
311    }
312
313    /// The product's own sentence about what to do.
314    pub fn detail(&self) -> &str {
315        match self {
316            Growth::Sealed(s) | Growth::OperatorOnly(s) | Growth::Unknown(s) => s,
317        }
318    }
319}
320
321#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
322pub enum Servable {
323    /// The product measured its own store.
324    Measured {
325        /// **Bytes that may still be handed to tenants**, beyond what they already
326        /// hold, after whatever reserve the product keeps for itself. gunnar's
327        /// `servable_bytes` (`free − disk floor`) — never its `free_bytes`, which
328        /// includes a reserve monetize must not sell.
329        servable_bytes: u64,
330        /// The whole store, reserve and all. `used + servable_bytes` is what the
331        /// fleet could grow to; `total_bytes` is larger than that by the reserve
332        /// and by anything on the filesystem that is not tenant data. 0 when the
333        /// product measures what is left but not what there is in total.
334        total_bytes: u64,
335        /// **Whether the store can EVER hold more than `total_bytes`.** A
336        /// measurement of the box, independent of the bytes: gunnar's
337        /// `Admin.Capacity` answers it as `growth`, and it is the field that
338        /// stops "buy another disk" being the reflex answer to a full fleet.
339        growth: Growth,
340        /// **The box's MEASURED ceilings on product-scope meters**, by the
341        /// product's own meter name: gunnar's `cpu_millicores_total` and
342        /// `ram_bytes_total` (Admin.Capacity, since gunnar 744bf72d) land here
343        /// as `cpu_millicores` / `ram_bytes`. A meter ABSENT here was not
344        /// measured — the wire spells that as an absent field, never as 0, and
345        /// a reader must never take a 0 as a ceiling. An order that would
346        /// raise a product-scope cap past a number here is refused by name;
347        /// the box's DECLARED ceilings (`CloudProvider::ceilings`) carry only
348        /// for a meter this map does not hold.
349        ceilings: BTreeMap<String, u64>,
350    },
351    /// **It could not be asked, or would not say — which is not zero.** The
352    /// string names WHICH: an RPC the deployed build predates, a control plane
353    /// that is down, a plugin that was never wired.
354    Unmeasured(String),
355}
356
357/// One named meter. Names are the product's, documented in its plugin crate, e.g.
358/// `pack_bytes`, `cache_bytes`, `lfs_bytes`, `tombstoned_bytes`, `open_store_ram_bytes`,
359/// `pushes`, `anonymous_reads`. Values are the product's units, usually bytes or counts.
360#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
361pub struct Usage {
362    pub meters: BTreeMap<String, u64>,
363    pub measured_at_unix_ms: u64,
364}
365
366/// The verdict monetize pushes back. Product-agnostic; the plugin maps it to the
367/// product's own enum (gunnar: `EntitlementState`).
368#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
369pub struct EntitlementFact {
370    pub tenant: TenantId,
371    /// **The ORDER this verdict came from** — the ledger reference
372    /// (`<product>/<tenant>/<date>+<caps>`) for a purchase, an operator's own
373    /// label (or nothing) for a hand-set fact. The field is still called `plan`
374    /// on the wire and in the signed canonical form (`monetize_embed::signing`),
375    /// because gunnar verifies that form and a renamed field would invalidate
376    /// every signature a deployed gunnar checks; the CONTENT is an order
377    /// reference since the plan catalogue went on 2026-09-05.
378    pub plan: String,
379    pub state: State,
380    pub paid_until_unix_ms: Option<u64>,
381    /// Per-meter caps the product enforces itself (gunnar: `pack_quota_bytes`,
382    /// `explode_budget_bytes`). Absent = product default.
383    pub caps: BTreeMap<String, u64>,
384    /// `operator` | `payment:<vendor>:<reference>` — lands in the product's attestation log.
385    pub source: String,
386    /// Ed25519 over the canonical JSON of the fields above, by monetize-server's key.
387    pub signature: Vec<u8>,
388}
389
390/// The ladder. Numbers (grace/retention days) are the deployment's policy
391/// (`monetize::Policy`), not the enum's.
392#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
393pub enum State {
394    Free,
395    Paid,
396    Grace,
397    Suspended,
398    Retention,
399}
400
401#[derive(Clone, Debug)]
402pub enum ProductError {
403    /// The product's control plane refused (auth, unknown tenant).
404    Refused(String),
405    /// Product unreachable. Retryable; nothing was written.
406    Unavailable(String),
407}
408
409impl std::fmt::Display for ProductError {
410    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411        match self {
412            ProductError::Refused(r) => write!(f, "product refused: {r}"),
413            ProductError::Unavailable(r) => write!(f, "product unavailable: {r}"),
414        }
415    }
416}
417impl std::error::Error for ProductError {}
418
419/// The plugin seam. Sync for the same reason as the vendor traits.
420pub trait Product: Send + Sync {
421    /// `gunnar`, `holger`, `njord`. Also the first segment of every payment reference.
422    fn id(&self) -> &'static str;
423    /// The meters this product declares — the UI and the order form read this,
424    /// so an order can only cap a meter that exists, and the console can say
425    /// whether a cap on it is policed ([`Enforcement`]).
426    fn meters(&self) -> &[Meter];
427
428    fn list_tenants(&self) -> Result<Vec<TenantId>, ProductError>;
429    fn read_usage(&self, tenant: &TenantId) -> Result<Usage, ProductError>;
430    fn push_entitlement(&self, fact: &EntitlementFact) -> Result<(), ProductError>;
431
432    /// **Can this product actually DELIVER these caps to this tenant, today?**
433    /// `Err(reason)` is a refusal that names what is missing.
434    ///
435    /// # Why it exists
436    ///
437    /// Everything else in monetize checks whether the money can be taken and
438    /// whether the iron can be bought. Nothing asked the third question, and it
439    /// is the one that decides whether the sale is honest: **once the disk is
440    /// attached, can the product use it?**
441    ///
442    /// For gunnar today the answer is often NO. The appliance's `/data` is a
443    /// 4x75 GB raid0 xfs set laid once at install (`InstallMode::Fresh`), there
444    /// is no runtime data-set growth, and gunnar is PID 1 with no shell — so a
445    /// disk monetize buys and attaches is **billed and invisible**. Selling a
446    /// bigger quota there does not fail; it succeeds, charges the customer, signs
447    /// a fact promising capacity, and the capacity is not there. That is the
448    /// worst failure shape this system has: money moved, everything green, the
449    /// promise hollow.
450    ///
451    /// # The contract
452    ///
453    /// * `caps` is the FULL target cap set, not a delta — the product is asked
454    ///   about the world it would have to serve, not about the change.
455    /// * It is asked **before the reserve**, so a refusal costs nothing and
456    ///   nothing has to be unwound. See
457    ///   `monetize::transaction::Transaction::increase`.
458    /// * `Ok(())` is a claim, not a shrug. A plugin that cannot tell must say so
459    ///   in an `Err`, because a plugin that guesses yes is indistinguishable
460    ///   from one that knows, right up until a tenant is charged for nothing.
461    /// * It has **no default implementation**, deliberately. A default `Ok(())`
462    ///   would let a product that never thought about this answer yes forever,
463    ///   which is exactly the silence this method exists to break.
464    ///
465    /// It may talk to the product (it is the plugin's own control plane), so it
466    /// may fail for the usual reasons; report those as a refusal with the reason
467    /// in it rather than inventing a yes.
468    fn can_absorb(&self, tenant: &TenantId, caps: &BTreeMap<String, u64>) -> Result<(), String>;
469
470    /// **How much this product could still serve, across the whole product.**
471    ///
472    /// The denominator of `UI.md`'s `184 / 300 GiB` and the servable half of the
473    /// oversell number (`monetize::fleet`). [`Product::can_absorb`] asks the same
474    /// box a narrower question — *can you take THIS tenant to THIS cap* — and
475    /// answers yes or no; this one asks for the figure, because a fleet total
476    /// needs a number and not a verdict.
477    ///
478    /// **It has no default implementation, for [`Product::can_absorb`]'s reason.**
479    /// A default would let a product that never thought about capacity answer
480    /// forever, and whichever constant it returned would be a lie: 0 reads as a
481    /// full disk, `u64::MAX` reads as an empty one, and the truth for a product
482    /// that cannot measure is [`Servable::Unmeasured`] — which the plugin must
483    /// say in its own words, naming what is missing.
484    ///
485    /// It may talk to the product, so it may fail for the usual reasons; report
486    /// those as `Unmeasured` with the reason in them rather than inventing a
487    /// number.
488    fn servable(&self) -> Servable;
489
490    /// **Delete everything this tenant has in the product, and keep them locked
491    /// out while it happens.**
492    ///
493    /// The product half of a super purge. `monetize` can give the IRON back on
494    /// its own — `transaction::release_tenant` and
495    /// `CloudProvider::release_for` — and it cannot delete a byte of what is on
496    /// it, because only the product knows what a tenant's data IS. In gunnar it
497    /// is every repository in the account plus two classes of bytes that are
498    /// not in the catalog at all; nothing on this side of the seam could
499    /// enumerate that.
500    ///
501    /// # It must LOCK, and the lock is the product's
502    ///
503    /// A purge is not atomic — it walks and reclaims over minutes while the
504    /// product goes on serving — so anything the tenant does during the walk
505    /// lands behind it. The lockout that prevents that can only live where the
506    /// requests arrive, which is the product. gunnar's is `Accounts.Gate`, and
507    /// it is total rather than read-only because in gunnar a read creates
508    /// things: a fetch explodes objects into a cache the purge just unlinked.
509    ///
510    /// [`Purged::still_locked`] carries the outcome back, because a product
511    /// that emptied a tenant and could not let them back in has left an account
512    /// nobody can use, and that must not read as success anywhere above here.
513    ///
514    /// # The default REFUSES BY NAME
515    ///
516    /// Unlike [`Product::can_absorb`] and [`Product::servable`], which have no
517    /// default at all because every constant they could return is a lie about
518    /// capacity, this one's dangerous answer is a permissive `Ok` — a product
519    /// that never implemented it reporting a tenant's data gone when it is
520    /// still there, after which a super purge would cheerfully destroy the
521    /// disks it is on. A refusal naming the product is the safe wrong answer
522    /// and it says which product owes the work.
523    fn purge_tenant(&self, tenant: &TenantId, reason: &str) -> Result<Purged, ProductError> {
524        let _ = (tenant, reason);
525        Err(ProductError::Refused(format!(
526            "the {} plugin cannot purge a tenant's data: it has no purge verb, so nothing here \
527             can promise the tenant's bytes are gone. Empty the tenant in the product itself \
528             before releasing its resources.",
529            self.id()
530        )))
531    }
532}
533
534/// What [`Product::purge_tenant`] did.
535#[derive(Clone, PartialEq, Eq, Debug)]
536pub struct Purged {
537    pub tenant: TenantId,
538    /// What the product removed, in its own words and its own units — "5 of 5
539    /// stores, 1.2 GiB". Free text because every product counts different
540    /// things and a shared schema would force each of them to lie a little.
541    pub detail: String,
542    /// Bytes the product says it reclaimed. `None` means it does not count
543    /// them, which is not the same as zero and must not be rendered as it.
544    pub bytes_reclaimed: Option<u64>,
545    /// **Is the tenant still locked out of the product?**
546    ///
547    /// `true` is a real and expected outcome — an operator may have suspended
548    /// the account separately, and the purge correctly refuses to lift a
549    /// lockout it did not take — but it is also what a purge that could not
550    /// unlock reports, and either way the tenant cannot use what is left.
551    /// Carried so a super purge can say so rather than infer it.
552    pub still_locked: bool,
553    /// One line per thing the product could not remove. Non-empty means the
554    /// tenant's data is PARTIALLY there, and the caller must not go on to
555    /// destroy the iron it is sitting on.
556    pub failures: Vec<String>,
557}
558
559/// **Bytes in the largest unit that still leaves a digit before the decimal
560/// point**, by integer arithmetic: `10.0 GiB`, `64.0 MiB`, `999 B`.
561///
562/// One writer, because there used to be three and every one of them carried the
563/// same bug. Each divided by 1 GiB unconditionally and printed one decimal, so
564/// anything under a gibibyte rendered `0.0 GiB` — and `0.0` beside a quantity is
565/// read as *there is none*:
566///
567/// * the console (then a Plans page) showed a real 64 MiB
568///   `pack_bytes` cap as `0.0 GiB`, which an operator reads as "no quota";
569/// * the old catalogue validator said *"sells pack_bytes = 10.0 GiB but
570///   its resources buy only 10.0 GiB — short 0.0 GiB"*, a sentence in which
571///   every number is wrong in the direction of "nothing is the matter";
572/// * `products/gunnar`'s capacity refusals said "short 0.0 GiB" for the same
573///   reason.
574///
575/// It lives here because this is the crate that already owns the meter
576/// vocabulary ([`Meter`], [`Backing::DiskBytes`]) and the only one every side
577/// can depend on: core, the product plugins, and the browser console alike. It
578/// pulls in nothing (this crate is `serde` and nothing else), so the wasm
579/// bundle pays a few hundred bytes for a formatter it was carrying anyway.
580///
581/// Truncates, never rounds up: a reading must not appear to cross a cap it has
582/// not crossed.
583pub fn bytes(n: u64) -> String {
584    const KIB: u64 = 1 << 10;
585    const MIB: u64 = 1 << 20;
586    const GIB: u64 = 1 << 30;
587    const TIB: u64 = 1 << 40;
588    let (unit, per) = match n {
589        n if n >= TIB => ("TiB", TIB),
590        n if n >= GIB => ("GiB", GIB),
591        n if n >= MIB => ("MiB", MIB),
592        n if n >= KIB => ("KiB", KIB),
593        // Under a kibibyte there is nothing to scale to, and `0.0 KiB` would be
594        // the same lie one unit down. Bytes are exact and short.
595        n => return format!("{n} B"),
596    };
597    format!("{}.{} {unit}", n / per, ((n % per) * 10) / per)
598}
599
600#[cfg(test)]
601mod backing_tests {
602    use super::Backing;
603
604    /// **A byte over the GiB is sold the next GiB, and a millicore is priced
605    /// as a thousandth of a core.** Distinct non-zero numbers: 2200 öre per
606    /// GiB-month, 90 GiB + 1 byte, 1500 millicores at 30 000.
607    #[test]
608    fn prices_round_bytes_up_to_the_unit_and_cpu_by_the_millicore() {
609        const GIB: u64 = 1 << 30;
610        assert_eq!(Backing::DiskBytes.price_month(90 * GIB, 2200), 198_000, "90 GiB × 22.00 SEK");
611        assert_eq!(Backing::DiskBytes.price_month(90 * GIB + 1, 2200), 200_200, "one byte over is the 91st GiB");
612        assert_eq!(Backing::DiskBytes.price_month(0, 2200), 0);
613        assert_eq!(Backing::RamBytes.price_month(3 * GIB, 700), 2100);
614        assert_eq!(Backing::CpuMillicores.price_month(1500, 30_000), 45_000, "1.5 cores at 300.00");
615        assert_eq!(Backing::CpuMillicores.price_month(1, 30_000), 30, "one millicore is not free and not a core");
616        // A zero unit price is FREE, whatever the delta: the open-source list.
617        for b in Backing::ALL {
618            assert_eq!(b.price_month(u64::MAX / 4, 0), 0, "{b:?}");
619        }
620        // The unit names are the price list's keys, both ways.
621        for b in Backing::ALL {
622            assert_eq!(Backing::from_unit(b.unit()), Some(b));
623        }
624        assert_eq!(Backing::from_unit("moon_month"), None);
625    }
626}
627
628#[cfg(test)]
629mod bytes_tests {
630    use super::bytes;
631
632    /// **A quantity that exists never renders as zero.** RED before this
633    /// function existed: `0.0 GiB` for every value under 2^30, in three separate
634    /// copies of the same six lines.
635    #[test]
636    fn only_a_genuine_zero_reads_as_zero() {
637        for n in [1u64, 512, 1 << 20, 67_108_864, (1 << 30) - 1] {
638            let s = bytes(n);
639            assert!(!s.starts_with("0.0 ") && !s.starts_with("0 "), "{n} bytes rendered as {s:?}, which reads as nothing");
640        }
641        assert_eq!(bytes(0), "0 B");
642    }
643
644    #[test]
645    fn the_unit_scales_and_the_value_truncates() {
646        assert_eq!(bytes(999), "999 B");
647        assert_eq!(bytes(1536), "1.5 KiB");
648        assert_eq!(bytes(67_108_864), "64.0 MiB");
649        assert_eq!(bytes((1 << 30) - 1), "1023.9 MiB", "truncates, never rounds up past the cap");
650        assert_eq!(bytes(10 << 30), "10.0 GiB");
651        assert_eq!(bytes(3 << 40), "3.0 TiB");
652        assert_eq!(bytes(u64::MAX), "16777215.9 TiB", "no overflow at the top of the range");
653    }
654}