monetize-product 0.1.0

The product plugin trait for monetize: how a metered product reports Usage and receives an Entitlement. Types and one trait, serde only — no ledger, no vendor, no network.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
//! **`Product` — the plugin that makes monetize speak one product's language.**
//!
//! monetize handles MANY products, and each product's plugin lives in this
//! repository. So monetize is
//! one service for gunnar.rs, holger.rs, njord, …, and each product is a crate under
//! `products/<name>` implementing this trait. The product itself carries only the thin
//! `monetize-embed` hook behind a cargo feature; it never learns what capacity costs.
//!
//! ```text
//!   product box ──Usage (gRPC, the product's own API)──▶ Product plugin ──▶ monetize core
//!   product box ◀──Entitlement (product's own API)──── Product plugin ◀── monetize core
//! ```
//!
//! # Two directions, both facts
//!
//! * [`Product::read_usage`] — what a tenant is consuming, in the product's own units
//!   (gunnar: pack bytes, read-cache bytes, LFS bytes, tombstoned bytes, open-store RAM;
//!   holger: whatever holger meters). The plugin maps them to [`Usage`], a flat bag of
//!   named meters, so core never has product-specific fields.
//! * [`Product::push_entitlement`] — the verdict, written back over the product's own
//!   control plane (gunnar: `Entitlement.Set` with `source = payment:<vendor>:<ref>`).
//!   Signed by monetize's key; the product appends it to its attestation log.
//!
//! # A plugin never blocks the product
//!
//! Counting is done **inside the product, always, open source** — a self-hoster wants the
//! numbers too. The plugin only *reads* them. If monetize is down the product keeps
//! serving on its cached entitlement; the plugin's job on reconnect is to catch up, not
//! to have been in the request path.

use std::collections::BTreeMap;

/// A tenant, as the product names it. gunnar: the `Namespace` name (`team/sub`).
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug, serde::Serialize, serde::Deserialize)]
pub struct TenantId(pub String);

/// **Whether the product will actually POLICE a cap on this meter.**
///
/// CPU and RAM exist as quota even where gunnar cannot cap them, and that gap is
/// narrowed without redoing the engine.
///
/// An order may cap any declared meter. But a cap on a meter nothing checks is a
/// promise nobody keeps, and the tenant cannot tell the difference from the
/// invoice. So the product says, per meter, which kind of promise it is — and
/// the console shows it beside the number rather than letting every cap look
/// like a guarantee.
///
/// This is the smallest honest way to let CPU and RAM be sold: they ARE bought,
/// with real money, and they DO change what the box can do — they are simply not
/// something a git server polices per namespace. Saying so is better than either
/// refusing to sell them or pretending they are enforced.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum Enforcement {
    /// **The product refuses when over.** A cap here is a wall the tenant hits.
    /// gunnar: `pack_bytes` (receive-pack refused before the bytes),
    /// `cache_bytes` (the explode budget).
    Enforced,
    /// **Counted and reported, not policed.** The number is true and monetize
    /// can bill on it or a human can act on it, but nothing refuses. A cap here
    /// is a threshold, not a wall.
    Measured,
    /// **Neither counted nor policed BY THE PRODUCT — it is iron monetize
    /// bought.** CPU and RAM: a bigger plan at the provider, real money, real
    /// effect, and no per-namespace check anywhere in the product. The
    /// provider's invoice is the enforcement.
    Provisioned,
}

impl Enforcement {
    /// One line for the console, beside the cap.
    pub fn describe(self) -> &'static str {
        match self {
            Enforcement::Enforced => "the product refuses when over",
            Enforcement::Measured => "counted and reported; nothing refuses",
            Enforcement::Provisioned => "provisioned at the cloud provider; not policed by the product",
        }
    }

    /// Is a cap on this meter a wall the tenant will actually hit?
    pub fn is_a_wall(self) -> bool {
        matches!(self, Enforcement::Enforced)
    }
}

/// **What a cap on this meter is MADE OF**, for the meters where a cap is a
/// promise of CAPACITY rather than a count — and therefore what UNIT the price
/// list prices it in and what iron the transaction buys for it.
///
/// A plan that sells bytes it never buys is the one bug here that silently
/// converts money into nothing. Since the plan catalogue went (2026-09-05), an
/// ORDER names caps and nothing else, and this is the field that turns a cap
/// into a purchase: `monetize::order::resources_for` translates the DELTA on a
/// disk-backed meter into a `Resource::Disk`, and `monetize::PriceList` prices
/// each backing's delta by its own unit.
///
/// It lives on the meter and not on the order because only the PRODUCT knows
/// what its meter is made of — that `pack_bytes` is block storage and `pushes`
/// is a count of events that costs nothing to promise.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum Backing {
    /// **Bytes of block storage.** A delta here becomes a `Resource::Disk` of
    /// `ceil(bytes / 2^30)` GiB, and is priced per GiB per month
    /// (`disk_gib_month` on the price list).
    DiskBytes,
    /// **CPU, in millicores (1000 = one core).** Priced per core per month
    /// (`cpu_core_month`). monetize does NOT translate millicores into a
    /// provider's plan name (`2xCPU-4GB` is a name, and a table of names goes
    /// stale every time a provider renames a size), so a delta here buys no
    /// cloud resource of its own: the ceiling is the product's own measurement
    /// (`Product::can_absorb`) and the box's declared limit
    /// (`CloudProvider::ceilings`).
    CpuMillicores,
    /// **RAM, in bytes.** Priced per GiB per month (`ram_gib_month`); the same
    /// ceiling rule as [`Backing::CpuMillicores`].
    RamBytes,
}

impl Backing {
    /// The price-list key this backing is priced under, and the unit it means:
    /// `disk_gib_month` | `cpu_core_month` | `ram_gib_month`.
    pub fn unit(self) -> &'static str {
        match self {
            Backing::DiskBytes => "disk_gib_month",
            Backing::CpuMillicores => "cpu_core_month",
            Backing::RamBytes => "ram_gib_month",
        }
    }

    /// Every backing, in the order the price list is written in.
    pub const ALL: [Backing; 3] = [Backing::DiskBytes, Backing::CpuMillicores, Backing::RamBytes];

    /// The backing a price-list key names, if any.
    pub fn from_unit(unit: &str) -> Option<Backing> {
        Backing::ALL.into_iter().find(|b| b.unit() == unit)
    }

    /// **Price `delta` of this backing at `minor_per_unit_month`, for one
    /// month**, in minor units. Integer arithmetic, rounded UP to the unit for
    /// bytes (a customer who asks for one byte over a GiB is sold the next GiB,
    /// which is what the provider sells us) and exact per millicore for CPU
    /// (`minor × millicores / 1000`).
    pub fn price_month(self, delta: u64, minor_per_unit_month: u64) -> u64 {
        const GIB: u64 = 1 << 30;
        match self {
            Backing::DiskBytes | Backing::RamBytes => delta.div_ceil(GIB).saturating_mul(minor_per_unit_month),
            Backing::CpuMillicores => u64::try_from(u128::from(delta) * u128::from(minor_per_unit_month) / 1000).unwrap_or(u64::MAX),
        }
    }
}

/// **WHERE a meter's number is even meaningful: per tenant, or per product.**
///
/// Disk is the whole game, and it is per tenant. CPU and RAM stay in the model,
/// but for gunnar they are not interesting per user — they are interesting per
/// product: a tenant does not own cores, the box does.
///
/// Orthogonal to [`Enforcement`], and the pair is what makes a fleet total
/// honest. `Enforcement` says whether a cap is a WALL. Scope says whether adding
/// one tenant's figure to another's produces anything at all:
///
/// * `pack_bytes` is per tenant, so nine tenants' pack bytes sum to the fleet's
///   pack bytes;
/// * `cpu_millicores` is per product, so nine tenants' CPU does not sum to
///   anything — nobody measured a tenant's cores, and adding nine numbers that
///   were never measured is arithmetic on nothing.
///
/// **It is the PRODUCT's call, not a global rule.** gunnar meters disk per tenant
/// and CPU per product; another product may legitimately meter CPU per user, or
/// disk only in total. [`Product::meters`] is already that seam, and nothing
/// outside a product decides which of its meters are per-tenant. `monetize::fleet`
/// sums only [`Scope::Tenant`], whatever a product declares.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum Scope {
    /// **Measured, or sold, per TENANT.** A fleet figure is the sum over tenants.
    /// The default, because it is what a meter usually is.
    Tenant,
    /// **Measured, or bought, for the WHOLE PRODUCT.** It appears ONCE, on the
    /// product screen, and never in a per-tenant sum. A cap on it may still be
    /// sold — CPU and RAM are bought with real money — it simply is not a
    /// quantity any one tenant holds.
    Product,
}

impl Scope {
    /// May a figure for this meter be summed across tenants? Only [`Scope::Tenant`].
    pub fn sums_across_tenants(self) -> bool {
        matches!(self, Scope::Tenant)
    }

    /// `tenant` | `product` — the wire and JSON spelling, one writer.
    pub fn name(self) -> &'static str {
        match self {
            Scope::Tenant => "tenant",
            Scope::Product => "product",
        }
    }
}

/// **One meter a product declares**: its name, what it means, and whether a cap
/// on it is policed. An order may only cap a declared meter, and the console
/// shows [`Enforcement`] beside the number.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Meter {
    /// The wire name, and the key in [`EntitlementFact::caps`] and [`Usage::meters`].
    pub name: &'static str,
    /// One line, the plugin's own words.
    pub meaning: &'static str,
    pub enforcement: Enforcement,
    /// **Does [`Product::read_usage`] carry a reading for this meter?**
    ///
    /// A meter exists to do one or both of two jobs: carry a usage READING, and
    /// accept a CAP. Most do both. Two kinds do not, and conflating them was
    /// caught by a test rather than by thinking:
    ///
    /// * `cpu_millicores`, `ram_bytes` — the product has no idea what was
    ///   bought; monetize does, from the order. Reporting `0` would read as "you
    ///   have none", which is worse than saying nothing.
    /// * `concurrent_transfers` — a cap the engine enforces instantly, with no
    ///   stored reading to bill from. The limit is real; the gauge does not exist.
    ///
    /// So a plugin's `read_usage` must report EXACTLY the meters with this set,
    /// which is an equality a test can hold in both directions: an undeclared
    /// reading is a leak, and a declared reading that stopped arriving is a
    /// silently emptied bill.
    pub reports_usage: bool,
    /// **What a cap on this meter is made of**, or `None` when a cap here costs
    /// nothing to grant (a count of pushes, a number of repositories the
    /// existing box already holds). `Some` is what makes an order on this meter
    /// a PURCHASE: the delta is priced by the backing's unit and, for disk,
    /// bought as iron. See [`Backing`].
    pub backing: Option<Backing>,
    /// **Whether this meter's number is per tenant or per product.** See
    /// [`Scope`]; it is what keeps a fleet total from adding up figures nobody
    /// measured per tenant.
    pub scope: Scope,
}

impl Meter {
    /// A meter that both reports a reading and may be capped — the common case.
    pub const fn new(name: &'static str, meaning: &'static str, enforcement: Enforcement) -> Meter {
        Meter { name, meaning, enforcement, reports_usage: true, backing: None, scope: Scope::Tenant }
    }

    /// A meter that may be CAPPED but carries no reading. See
    /// [`Meter::reports_usage`] for the two kinds and why `0` is not a truthful
    /// substitute.
    pub const fn cap_only(name: &'static str, meaning: &'static str, enforcement: Enforcement) -> Meter {
        Meter { name, meaning, enforcement, reports_usage: false, backing: None, scope: Scope::Tenant }
    }

    /// The same meter, declaring what a cap on it is MADE OF — and so what an
    /// order on it buys and what the price list prices it by. A
    /// [`Enforcement::Provisioned`] meter without one is a cap monetize can
    /// neither price nor buy; gunnar's own test holds its list to that.
    pub const fn with_backing(self, backing: Backing) -> Meter {
        Meter { backing: Some(backing), ..self }
    }

    /// The same meter, measured for the WHOLE PRODUCT rather than per tenant —
    /// so a fleet total leaves it out instead of adding up figures nobody took.
    /// See [`Scope`].
    pub const fn per_product(self) -> Meter {
        Meter { scope: Scope::Product, ..self }
    }
}

/// **What a product says it could still serve, or why it could not say.**
///
/// The other half of the oversell number: `sum(what has been sold) − servable`.
/// Without it monetize could sell ten tenants 10 GiB each on a box with 55 GiB
/// servable and nothing would object until the sixth push.
///
/// The two arms are the distinction `BASE-MODEL.md` rule 3 turns on and the same
/// one [`Product::can_absorb`] already makes: **an unanswered capacity question
/// is a measurement that did not happen, not a full disk and not an empty one.**
/// The live gunnar.rs appliance predates gunnar's `Admin.Capacity` RPC and
/// answers [`Servable::Unmeasured`] today; a fleet total that turned that into a
/// zero would report every deployment as catastrophically oversold, and one that
/// turned it into infinity would report every deployment as fine. Neither is a
/// measurement.
/// **Can this product's store grow while it runs?** Carried on
/// [`Servable::Measured`] beside the bytes, because the two answer different
/// questions and an operator acts differently on each: a full box that can grow
/// wants a disk; a full box that is SEALED wants a fresh install onto a larger
/// set, and a disk bought for it is billed and invisible.
///
/// Each arm carries the PRODUCT's own remediation sentence (gunnar's
/// `remediation()`), so the console renders what the product said rather than a
/// paraphrase of it — `UI.md` screen 2's `⚠ cannot grow` badge holds exactly
/// that paragraph.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum Growth {
    /// Cannot gain capacity while it runs, and nothing on the machine can give
    /// it any. `GROWTH_SEALED` on gunnar's wire.
    Sealed(String),
    /// The product will not grow the set itself, but an operator can, out of
    /// band. `GROWTH_OPERATOR_ONLY`.
    OperatorOnly(String),
    /// The product could not tell. NOT a promise that it can grow.
    Unknown(String),
}

impl Growth {
    /// The wire word: `sealed` | `operator_only` | `unknown`.
    pub fn name(&self) -> &'static str {
        match self {
            Growth::Sealed(_) => "sealed",
            Growth::OperatorOnly(_) => "operator_only",
            Growth::Unknown(_) => "unknown",
        }
    }

    /// The product's own sentence about what to do.
    pub fn detail(&self) -> &str {
        match self {
            Growth::Sealed(s) | Growth::OperatorOnly(s) | Growth::Unknown(s) => s,
        }
    }
}

#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum Servable {
    /// The product measured its own store.
    Measured {
        /// **Bytes that may still be handed to tenants**, beyond what they already
        /// hold, after whatever reserve the product keeps for itself. gunnar's
        /// `servable_bytes` (`free − disk floor`) — never its `free_bytes`, which
        /// includes a reserve monetize must not sell.
        servable_bytes: u64,
        /// The whole store, reserve and all. `used + servable_bytes` is what the
        /// fleet could grow to; `total_bytes` is larger than that by the reserve
        /// and by anything on the filesystem that is not tenant data. 0 when the
        /// product measures what is left but not what there is in total.
        total_bytes: u64,
        /// **Whether the store can EVER hold more than `total_bytes`.** A
        /// measurement of the box, independent of the bytes: gunnar's
        /// `Admin.Capacity` answers it as `growth`, and it is the field that
        /// stops "buy another disk" being the reflex answer to a full fleet.
        growth: Growth,
        /// **The box's MEASURED ceilings on product-scope meters**, by the
        /// product's own meter name: gunnar's `cpu_millicores_total` and
        /// `ram_bytes_total` (Admin.Capacity, since gunnar 744bf72d) land here
        /// as `cpu_millicores` / `ram_bytes`. A meter ABSENT here was not
        /// measured — the wire spells that as an absent field, never as 0, and
        /// a reader must never take a 0 as a ceiling. An order that would
        /// raise a product-scope cap past a number here is refused by name;
        /// the box's DECLARED ceilings (`CloudProvider::ceilings`) carry only
        /// for a meter this map does not hold.
        ceilings: BTreeMap<String, u64>,
    },
    /// **It could not be asked, or would not say — which is not zero.** The
    /// string names WHICH: an RPC the deployed build predates, a control plane
    /// that is down, a plugin that was never wired.
    Unmeasured(String),
}

/// One named meter. Names are the product's, documented in its plugin crate, e.g.
/// `pack_bytes`, `cache_bytes`, `lfs_bytes`, `tombstoned_bytes`, `open_store_ram_bytes`,
/// `pushes`, `anonymous_reads`. Values are the product's units, usually bytes or counts.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Usage {
    pub meters: BTreeMap<String, u64>,
    pub measured_at_unix_ms: u64,
}

/// The verdict monetize pushes back. Product-agnostic; the plugin maps it to the
/// product's own enum (gunnar: `EntitlementState`).
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct EntitlementFact {
    pub tenant: TenantId,
    /// **The ORDER this verdict came from** — the ledger reference
    /// (`<product>/<tenant>/<date>+<caps>`) for a purchase, an operator's own
    /// label (or nothing) for a hand-set fact. The field is still called `plan`
    /// on the wire and in the signed canonical form (`monetize_embed::signing`),
    /// because gunnar verifies that form and a renamed field would invalidate
    /// every signature a deployed gunnar checks; the CONTENT is an order
    /// reference since the plan catalogue went on 2026-09-05.
    pub plan: String,
    pub state: State,
    pub paid_until_unix_ms: Option<u64>,
    /// Per-meter caps the product enforces itself (gunnar: `pack_quota_bytes`,
    /// `explode_budget_bytes`). Absent = product default.
    pub caps: BTreeMap<String, u64>,
    /// `operator` | `payment:<vendor>:<reference>` — lands in the product's attestation log.
    pub source: String,
    /// Ed25519 over the canonical JSON of the fields above, by monetize-server's key.
    pub signature: Vec<u8>,
}

/// The ladder. Numbers (grace/retention days) are the deployment's policy
/// (`monetize::Policy`), not the enum's.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum State {
    Free,
    Paid,
    Grace,
    Suspended,
    Retention,
}

#[derive(Clone, Debug)]
pub enum ProductError {
    /// The product's control plane refused (auth, unknown tenant).
    Refused(String),
    /// Product unreachable. Retryable; nothing was written.
    Unavailable(String),
}

impl std::fmt::Display for ProductError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ProductError::Refused(r) => write!(f, "product refused: {r}"),
            ProductError::Unavailable(r) => write!(f, "product unavailable: {r}"),
        }
    }
}
impl std::error::Error for ProductError {}

/// The plugin seam. Sync for the same reason as the vendor traits.
pub trait Product: Send + Sync {
    /// `gunnar`, `holger`, `njord`. Also the first segment of every payment reference.
    fn id(&self) -> &'static str;
    /// The meters this product declares — the UI and the order form read this,
    /// so an order can only cap a meter that exists, and the console can say
    /// whether a cap on it is policed ([`Enforcement`]).
    fn meters(&self) -> &[Meter];

    fn list_tenants(&self) -> Result<Vec<TenantId>, ProductError>;
    fn read_usage(&self, tenant: &TenantId) -> Result<Usage, ProductError>;
    fn push_entitlement(&self, fact: &EntitlementFact) -> Result<(), ProductError>;

    /// **Can this product actually DELIVER these caps to this tenant, today?**
    /// `Err(reason)` is a refusal that names what is missing.
    ///
    /// # Why it exists
    ///
    /// Everything else in monetize checks whether the money can be taken and
    /// whether the iron can be bought. Nothing asked the third question, and it
    /// is the one that decides whether the sale is honest: **once the disk is
    /// attached, can the product use it?**
    ///
    /// For gunnar today the answer is often NO. The appliance's `/data` is a
    /// 4x75 GB raid0 xfs set laid once at install (`InstallMode::Fresh`), there
    /// is no runtime data-set growth, and gunnar is PID 1 with no shell — so a
    /// disk monetize buys and attaches is **billed and invisible**. Selling a
    /// bigger quota there does not fail; it succeeds, charges the customer, signs
    /// a fact promising capacity, and the capacity is not there. That is the
    /// worst failure shape this system has: money moved, everything green, the
    /// promise hollow.
    ///
    /// # The contract
    ///
    /// * `caps` is the FULL target cap set, not a delta — the product is asked
    ///   about the world it would have to serve, not about the change.
    /// * It is asked **before the reserve**, so a refusal costs nothing and
    ///   nothing has to be unwound. See
    ///   `monetize::transaction::Transaction::increase`.
    /// * `Ok(())` is a claim, not a shrug. A plugin that cannot tell must say so
    ///   in an `Err`, because a plugin that guesses yes is indistinguishable
    ///   from one that knows, right up until a tenant is charged for nothing.
    /// * It has **no default implementation**, deliberately. A default `Ok(())`
    ///   would let a product that never thought about this answer yes forever,
    ///   which is exactly the silence this method exists to break.
    ///
    /// It may talk to the product (it is the plugin's own control plane), so it
    /// may fail for the usual reasons; report those as a refusal with the reason
    /// in it rather than inventing a yes.
    fn can_absorb(&self, tenant: &TenantId, caps: &BTreeMap<String, u64>) -> Result<(), String>;

    /// **How much this product could still serve, across the whole product.**
    ///
    /// The denominator of `UI.md`'s `184 / 300 GiB` and the servable half of the
    /// oversell number (`monetize::fleet`). [`Product::can_absorb`] asks the same
    /// box a narrower question — *can you take THIS tenant to THIS cap* — and
    /// answers yes or no; this one asks for the figure, because a fleet total
    /// needs a number and not a verdict.
    ///
    /// **It has no default implementation, for [`Product::can_absorb`]'s reason.**
    /// A default would let a product that never thought about capacity answer
    /// forever, and whichever constant it returned would be a lie: 0 reads as a
    /// full disk, `u64::MAX` reads as an empty one, and the truth for a product
    /// that cannot measure is [`Servable::Unmeasured`] — which the plugin must
    /// say in its own words, naming what is missing.
    ///
    /// It may talk to the product, so it may fail for the usual reasons; report
    /// those as `Unmeasured` with the reason in them rather than inventing a
    /// number.
    fn servable(&self) -> Servable;

    /// **Delete everything this tenant has in the product, and keep them locked
    /// out while it happens.**
    ///
    /// The product half of a super purge. `monetize` can give the IRON back on
    /// its own — `transaction::release_tenant` and
    /// `CloudProvider::release_for` — and it cannot delete a byte of what is on
    /// it, because only the product knows what a tenant's data IS. In gunnar it
    /// is every repository in the account plus two classes of bytes that are
    /// not in the catalog at all; nothing on this side of the seam could
    /// enumerate that.
    ///
    /// # It must LOCK, and the lock is the product's
    ///
    /// A purge is not atomic — it walks and reclaims over minutes while the
    /// product goes on serving — so anything the tenant does during the walk
    /// lands behind it. The lockout that prevents that can only live where the
    /// requests arrive, which is the product. gunnar's is `Accounts.Gate`, and
    /// it is total rather than read-only because in gunnar a read creates
    /// things: a fetch explodes objects into a cache the purge just unlinked.
    ///
    /// [`Purged::still_locked`] carries the outcome back, because a product
    /// that emptied a tenant and could not let them back in has left an account
    /// nobody can use, and that must not read as success anywhere above here.
    ///
    /// # The default REFUSES BY NAME
    ///
    /// Unlike [`Product::can_absorb`] and [`Product::servable`], which have no
    /// default at all because every constant they could return is a lie about
    /// capacity, this one's dangerous answer is a permissive `Ok` — a product
    /// that never implemented it reporting a tenant's data gone when it is
    /// still there, after which a super purge would cheerfully destroy the
    /// disks it is on. A refusal naming the product is the safe wrong answer
    /// and it says which product owes the work.
    fn purge_tenant(&self, tenant: &TenantId, reason: &str) -> Result<Purged, ProductError> {
        let _ = (tenant, reason);
        Err(ProductError::Refused(format!(
            "the {} plugin cannot purge a tenant's data: it has no purge verb, so nothing here \
             can promise the tenant's bytes are gone. Empty the tenant in the product itself \
             before releasing its resources.",
            self.id()
        )))
    }
}

/// What [`Product::purge_tenant`] did.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Purged {
    pub tenant: TenantId,
    /// What the product removed, in its own words and its own units — "5 of 5
    /// stores, 1.2 GiB". Free text because every product counts different
    /// things and a shared schema would force each of them to lie a little.
    pub detail: String,
    /// Bytes the product says it reclaimed. `None` means it does not count
    /// them, which is not the same as zero and must not be rendered as it.
    pub bytes_reclaimed: Option<u64>,
    /// **Is the tenant still locked out of the product?**
    ///
    /// `true` is a real and expected outcome — an operator may have suspended
    /// the account separately, and the purge correctly refuses to lift a
    /// lockout it did not take — but it is also what a purge that could not
    /// unlock reports, and either way the tenant cannot use what is left.
    /// Carried so a super purge can say so rather than infer it.
    pub still_locked: bool,
    /// One line per thing the product could not remove. Non-empty means the
    /// tenant's data is PARTIALLY there, and the caller must not go on to
    /// destroy the iron it is sitting on.
    pub failures: Vec<String>,
}

/// **Bytes in the largest unit that still leaves a digit before the decimal
/// point**, by integer arithmetic: `10.0 GiB`, `64.0 MiB`, `999 B`.
///
/// One writer, because there used to be three and every one of them carried the
/// same bug. Each divided by 1 GiB unconditionally and printed one decimal, so
/// anything under a gibibyte rendered `0.0 GiB` — and `0.0` beside a quantity is
/// read as *there is none*:
///
/// * the console (then a Plans page) showed a real 64 MiB
///   `pack_bytes` cap as `0.0 GiB`, which an operator reads as "no quota";
/// * the old catalogue validator said *"sells pack_bytes = 10.0 GiB but
///   its resources buy only 10.0 GiB — short 0.0 GiB"*, a sentence in which
///   every number is wrong in the direction of "nothing is the matter";
/// * `products/gunnar`'s capacity refusals said "short 0.0 GiB" for the same
///   reason.
///
/// It lives here because this is the crate that already owns the meter
/// vocabulary ([`Meter`], [`Backing::DiskBytes`]) and the only one every side
/// can depend on: core, the product plugins, and the browser console alike. It
/// pulls in nothing (this crate is `serde` and nothing else), so the wasm
/// bundle pays a few hundred bytes for a formatter it was carrying anyway.
///
/// Truncates, never rounds up: a reading must not appear to cross a cap it has
/// not crossed.
pub fn bytes(n: u64) -> String {
    const KIB: u64 = 1 << 10;
    const MIB: u64 = 1 << 20;
    const GIB: u64 = 1 << 30;
    const TIB: u64 = 1 << 40;
    let (unit, per) = match n {
        n if n >= TIB => ("TiB", TIB),
        n if n >= GIB => ("GiB", GIB),
        n if n >= MIB => ("MiB", MIB),
        n if n >= KIB => ("KiB", KIB),
        // Under a kibibyte there is nothing to scale to, and `0.0 KiB` would be
        // the same lie one unit down. Bytes are exact and short.
        n => return format!("{n} B"),
    };
    format!("{}.{} {unit}", n / per, ((n % per) * 10) / per)
}

#[cfg(test)]
mod backing_tests {
    use super::Backing;

    /// **A byte over the GiB is sold the next GiB, and a millicore is priced
    /// as a thousandth of a core.** Distinct non-zero numbers: 2200 öre per
    /// GiB-month, 90 GiB + 1 byte, 1500 millicores at 30 000.
    #[test]
    fn prices_round_bytes_up_to_the_unit_and_cpu_by_the_millicore() {
        const GIB: u64 = 1 << 30;
        assert_eq!(Backing::DiskBytes.price_month(90 * GIB, 2200), 198_000, "90 GiB × 22.00 SEK");
        assert_eq!(Backing::DiskBytes.price_month(90 * GIB + 1, 2200), 200_200, "one byte over is the 91st GiB");
        assert_eq!(Backing::DiskBytes.price_month(0, 2200), 0);
        assert_eq!(Backing::RamBytes.price_month(3 * GIB, 700), 2100);
        assert_eq!(Backing::CpuMillicores.price_month(1500, 30_000), 45_000, "1.5 cores at 300.00");
        assert_eq!(Backing::CpuMillicores.price_month(1, 30_000), 30, "one millicore is not free and not a core");
        // A zero unit price is FREE, whatever the delta: the open-source list.
        for b in Backing::ALL {
            assert_eq!(b.price_month(u64::MAX / 4, 0), 0, "{b:?}");
        }
        // The unit names are the price list's keys, both ways.
        for b in Backing::ALL {
            assert_eq!(Backing::from_unit(b.unit()), Some(b));
        }
        assert_eq!(Backing::from_unit("moon_month"), None);
    }
}

#[cfg(test)]
mod bytes_tests {
    use super::bytes;

    /// **A quantity that exists never renders as zero.** RED before this
    /// function existed: `0.0 GiB` for every value under 2^30, in three separate
    /// copies of the same six lines.
    #[test]
    fn only_a_genuine_zero_reads_as_zero() {
        for n in [1u64, 512, 1 << 20, 67_108_864, (1 << 30) - 1] {
            let s = bytes(n);
            assert!(!s.starts_with("0.0 ") && !s.starts_with("0 "), "{n} bytes rendered as {s:?}, which reads as nothing");
        }
        assert_eq!(bytes(0), "0 B");
    }

    #[test]
    fn the_unit_scales_and_the_value_truncates() {
        assert_eq!(bytes(999), "999 B");
        assert_eq!(bytes(1536), "1.5 KiB");
        assert_eq!(bytes(67_108_864), "64.0 MiB");
        assert_eq!(bytes((1 << 30) - 1), "1023.9 MiB", "truncates, never rounds up past the cap");
        assert_eq!(bytes(10 << 30), "10.0 GiB");
        assert_eq!(bytes(3 << 40), "3.0 TiB");
        assert_eq!(bytes(u64::MAX), "16777215.9 TiB", "no overflow at the top of the range");
    }
}