Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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");
        }
    }
}