crates/monetize-product/src/pull.rs
//! **What an estate's box tells monetize about its product** (5.4, G1 pull).
//!
//! Monetize never calls INTO an estate. Every read the server loops used to dial the
//! product for — the tenant list and who each tenant is, each tenant's usage, how much
//! the box can still serve, whether its paid door is there, how much room a raise would
//! find — arrives instead in the box's own `Fleet.Report` (`monetize_embed::fleet::Report`,
//! field `product`, this struct as JSON). Everything that CHANGES the estate (an
//! entitlement, a new principal) leaves monetize as a signed order the box pulls
//! ([`order`]).
//!
//! The product plugin keeps every verb that needs no box (prices, meters, tickets); the
//! pull wrapper in `monetize-guardian` (`pulled::Pulled`) answers the rest from here.
use std::collections::BTreeMap;
use crate::{MeteredDoor, Servable, TenantOwner, Usage};
/// The order kinds a product's box runs beside the repair verbs. The body is JSON.
pub mod order {
/// Body: the signed [`crate::EntitlementFact`], exactly as `Entitlement.Set` took it.
/// The box verifies the fact's own signature under `monetize_pubkey` as before; newest
/// per tenant wins, so an unacked older fact for the same tenant is dropped when a new
/// one is queued.
pub const ENTITLEMENT: &str = "entitlement";
/// Body: [`super::AddPrincipal`]. The box creates the account (gunnar:
/// `Access.AddPrincipal`) and acks:
///
/// * `ok`, `detail` = the namespace it made (empty = the username) — also when the
/// principal is already there and was made for this same `signup` (idempotent: monetize
/// asks again when it lost the ack);
/// * not `ok`, `detail` starting with [`TAKEN`] — the name is somebody else's (the buyer
/// picks another, `Signup.Rename`);
/// * not `ok`, anything else — refused, final; the detail is the operator's words.
///
/// Monetize queues the signup's entitlement only after an `ok` ack, so a fact never lands
/// on a name that turned out to be somebody else's.
pub const ADD_PRINCIPAL: &str = "add_principal";
/// The ack-detail prefix of an `add_principal` refused because the name is taken.
pub const TAKEN: &str = "taken";
/// ★ UW-5. Body: [`super::AddUsers`]. **"Add users", granted** on the account — one named
/// user or `count` seats (gunnar: `Accounts.EnrollEmployee` as the seller, under `order`).
/// The box acks `ok` with `detail` = the product's `UsersAnswer` as JSON (a retry of the
/// same `order` answers the first grant, `unchanged`); not `ok` is refused, final — the
/// detail is the words, and monetize refunds what was paid for it. No address rides in
/// the order: monetize mails the invite itself.
pub const ADD_USERS: &str = "add_users";
/// ★ UW-4. Body: [`super::GrowRoom`]. **Room was sold past what the set has now**: queued,
/// and the set is to be resized (owner: the customer never sees a cap). The box keeps the
/// newest want, says it on its health for its operator, and acks `ok`; the resize itself
/// is the set's own growth. The newest want replaces an unacked older one.
pub const GROW_ROOM: &str = "grow_room";
/// ★ UW-11. Body: [`super::PurgeTenant`]. **The account is deleted for non-payment**
/// (nonpay's end of retention): with no `job`, the box starts the account's purge (or
/// adopts the purge of it already running) and acks `ok` with [`super::PurgeAnswer`] as
/// JSON; with a `job`, it answers that job's state the same way. A purge is minutes of
/// unlinking, so monetize asks again (a new order naming the job) until the answer is no
/// longer `running`. Not `ok` is refused for this ask, the detail its words: a job the
/// box no longer knows, a box that does not serve.
pub const PURGE_TENANT: &str = "purge_tenant";
/// `monetize_embed::fleet::GrowGoAhead`: **a sold estate's paid data step**, queued to the
/// boxes that hold the data once the step's money is captured. The box verifies the go-ahead
/// under monetize's key, then grows its own data disks through the ask [`super::ask::GROW_SET`].
pub const GROW_GO_AHEAD: &str = "grow_go_ahead";
/// `monetize_embed::fleet::SignInAgain`: **a sold estate's resend sign-in link**, queued to
/// the primary when its buyer asks. No secret rides in it: the box mints the invite itself.
pub const SIGN_IN_AGAIN: &str = "sign_in_again";
/// `monetize_embed::fleet::Refeed`: **estate keys fed again to an already-laid estate**
/// (MAIL-RELAY + NIGHT-PUBREPO, 2026-10-28): a box is fed its estate keys at LAY time only;
/// a key the ONE box holds later (the mains' mail relay, a site's `public_repositories`)
/// reaches a laid box through this one signed order, queued by the operator (`monetize site
/// refeed <site>`). The box takes each key it knows BY NAME (`monetize_embed::fleet::refeed`)
/// and refuses the order by name if one is not its own; it keeps them beside the feed it was
/// laid with, which they go over. The body may CARRY A SECRET (the relay's token): never
/// logged, never shown.
pub const REFEED: &str = "refeed";
/// **The core's order kinds, the one list** (A24 §5.7, A28 M25): what the ONE box queues to
/// any product's boxes. A product's guardian adds its own kinds (Gunnar's repair verbs,
/// `monetize_embed::fleet::kind::GUARDIAN`); a box refuses what it has no verb for BY NAME.
pub const CORE: [&str; 8] = [ENTITLEMENT, ADD_PRINCIPAL, ADD_USERS, GROW_ROOM, PURGE_TENANT, GROW_GO_AHEAD, SIGN_IN_AGAIN, REFEED];
}
/// The core's `Fleet.Ask` kind that is not a remedy: answered at once, no case opened.
pub mod ask {
/// **Size the calling box's own data disks** at monetize's provider: no more than is paid
/// for its role, never smaller.
pub const GROW_SET: &str = "grow_set";
/// Body: [`super::FetchAsk`]. **The box pulls the file a product order carries**
/// ([`super::ProductOrder::fetch`], MONETIZE-FIX 2026-11-01): the ONE box answers the bytes
/// in `FleetAskAnswer.answer`, only to a box with an order queued for it that names the file.
/// The ONE box never pushes a file into an estate.
pub const FETCH: &str = "fetch";
/// The core's ask kinds, the one list (A24 §5.7: 7 orders + 1 ask; MAIL-RELAY made it 8
/// orders: `refeed`; MONETIZE-FIX added the `fetch` ask a product order's file is pulled by).
pub const CORE: [&str; 2] = [GROW_SET, FETCH];
}
/// Whether `kind` is one of the core's 9 kinds ([`order::CORE`], [`ask::CORE`]).
pub fn is_core(kind: &str) -> bool {
order::CORE.contains(&kind) || ask::CORE.contains(&kind)
}
/// The body of an [`order::PURGE_TENANT`] order.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct PurgeTenant {
/// The account, as its own estate's main names it (no `@<estate>/`).
pub tenant: String,
/// Why, shown to the locked-out account and kept on its gate.
pub reason: String,
/// The job a start answered; empty: start (or adopt the running one).
pub job: String,
}
/// **The main's word about one purge job** — an [`order::PURGE_TENANT`] ack's detail.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct PurgeAnswer {
pub job: String,
/// Queued or running: not finished, the account stays locked.
pub running: bool,
/// The job FAILED: nothing of the account's may be released.
pub failed: bool,
pub error: String,
/// What was removed, in the product's words (`5 of 5 stores, 1.2 GiB`).
pub detail: String,
pub bytes_reclaimed: Option<u64>,
/// The lockout the purge took is still held.
pub still_locked: bool,
/// One line per store it could not reclaim.
pub failures: Vec<String>,
}
/// The body of an [`order::ADD_USERS`] order.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct AddUsers {
/// The account, as its own estate's main names it (no `@<estate>/`).
pub tenant: String,
/// What was asked — never the address (`email`, `invite_origin` are emptied).
pub ask: crate::UsersAsk,
/// The order reference the grant belongs to: a retry answers the first grant.
pub order: String,
}
/// The body of an [`order::GROW_ROOM`] order.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct GrowRoom {
/// Stored bytes sold past what the set serves now.
pub need_bytes: u64,
/// Why, for the estate's operator (which account, what it holds).
pub reason: String,
}
/// **One account's room for "Add users"**, as the main's own dry run says it.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct AccountRoom {
/// The account's room neither stored nor promised; `None`: nothing caps it.
pub unused_bytes: Option<u64>,
/// The room one user holds when the ask names none (the product's default).
pub room_bytes: u64,
pub seats: u32,
pub seats_open: u32,
}
/// **One box's word about its product**, as of `measured_unix_ms`.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ProductReport {
/// Every tenant, and who it is (`Pulled::tenant_owners`).
pub tenants: Vec<TenantOwner>,
/// Each tenant's usage, by tenant name (`Pulled::read_usage`).
pub usage: BTreeMap<String, Usage>,
/// What the product could still serve (`Pulled::servable`); absent = it did not say.
pub servable: Option<Servable>,
/// Whether the paid door answered on the box (`Pulled::metered_door`).
pub metered: Option<MeteredDoor>,
/// **The room a raise would find**, per meter: what may still be handed to tenants
/// beyond what they hold. `Pulled::can_absorb` answers from it.
pub room: BTreeMap<String, u64>,
/// What each tenant holds PROMISED today, per meter (its current caps), by tenant name.
/// A raise needs `target − promised` of `room`.
pub promised: BTreeMap<String, BTreeMap<String, u64>>,
/// When a raise does not fit `room`: `Some(note)` = a bought disk becomes usable (by the
/// hand `note` names), `None` = it does not, and the raise is refused.
pub with_iron: Option<String>,
/// **Every name the box holds** — principals and namespaces, a tenant or not. A signup's
/// name is free when it is absent here (and no queued `add_principal` asks for it):
/// monetize never asks the box.
pub names: Vec<String>,
/// ★ UW-5: each account's room for "Add users", by tenant name (`Pulled::users_fit`
/// answers from it). Absent: the box does not say, and nothing is guessed.
pub accounts: BTreeMap<String, AccountRoom>,
/// ★ UW-10: each tenant's kept activity over the last [`ACTIVITY_DAYS`] days, by tenant
/// name (`Pulled::read_activity` answers from it). Absent: the box did not say.
pub activity: BTreeMap<String, crate::Activity>,
pub measured_unix_ms: u64,
}
/// ★ UW-10: how many days of each tenant's activity a box's report carries.
pub const ACTIVITY_DAYS: u64 = 30;
/// The body of an [`order::ADD_PRINCIPAL`] order: a paid signup's account.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct AddPrincipal {
pub username: String,
/// `person` | `org`, the product's own words.
pub kind: String,
/// The signup this account is for (the reference its row carries).
pub signup: String,
}
/// **The body of a product's OWN order kind** (MONETIZE-FIX 2026-11-01): a kind the product's
/// facts declare (`monetize_product_guest::facts::OrderKindFacts`), queued by the operator on one
/// of his sites and pulled by the role the declaration names. The core checks the params against
/// the declaration and signs the order; what the kind does is the product's box's alone. The box
/// acks `ok` with its answer as `detail` (its words or JSON), or not `ok` with its refusal.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct ProductOrder {
/// The declared params, each a value as the operator typed it.
pub params: BTreeMap<String, String>,
/// The file the order carries, kept on the ONE box: the box pulls it ([`ask::FETCH`]).
#[serde(skip_serializing_if = "Option::is_none")]
pub fetch: Option<Fetch>,
}
/// **A file a product order carries**, named by its content.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct Fetch {
/// The file's sha256, lowercase hex: what the box asks for and checks what it got against.
pub id: String,
pub bytes: u64,
/// The declared param the file is (the operator's `--file <param>=<path>`).
pub param: String,
}
/// The body of an [`ask::FETCH`] ask.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct FetchAsk {
pub id: String,
}
#[cfg(test)]
mod core_kinds_tests {
use super::{ask, is_core, order};
/// A24 §5.7: the core kinds in this one list (A28 M25): 8 orders (MAIL-RELAY 2026-10-28 added
/// `refeed`) and 2 asks (MONETIZE-FIX 2026-11-01 added `fetch`). A product's own kinds are
/// its facts' (`orders`), never in this list.
#[test]
fn the_core_kinds_are_eight_orders_and_two_asks_in_one_list() {
let mut orders = order::CORE.to_vec();
orders.sort_unstable();
orders.dedup();
assert_eq!(orders.len(), 8, "eight distinct core orders");
assert_eq!(order::CORE, [order::ENTITLEMENT, order::ADD_PRINCIPAL, order::ADD_USERS, order::GROW_ROOM, order::PURGE_TENANT, order::GROW_GO_AHEAD, order::SIGN_IN_AGAIN, order::REFEED]);
assert_eq!(ask::CORE, [ask::GROW_SET, ask::FETCH]);
for k in order::CORE.iter().chain(ask::CORE.iter()) {
assert!(is_core(k), "{k} is core");
}
// The relay products' own guardian kinds are not the core's, nor is a kind nobody has.
for k in ["recharge_from_twin", "fence_primary", "adopt_twin", "review_twin", "restate_entitlements", "join_member", ""] {
assert!(!is_core(k), "{k:?} is not a core kind");
}
}
}