crates/gunnar-core/src/entitlement.rs
//! **Where a namespace's paid state lives** (`refs/gunnar/entitlements`) —
//! compiled only with the `metered` feature.
//!
//! The record monetize pushes over `Entitlement.Set`, kept in the control
//! store beside the catalog so a reboot loads it, versioned and replicated
//! with everything else. This module stores; it does not decide. The decision
//! (may this namespace push, right now) is `monetize_embed::EntitlementCache`'s,
//! in `gunnar-server`, out of the same facts — and the signature every fact
//! carries is verified THERE, against monetize's public key, before it is
//! stored here and again when it is loaded from here. A tampered control
//! document therefore does not move a plan: it produces a fact that no longer
//! verifies, which is refused and logged at boot.
//!
//! # The ladder, and the two rules that are not negotiable
//!
//! | state | `git push` | owner `git clone` | anonymous browse |
//! |---|---|---|---|
//! | Free, Paid | ✓ | ✓ | ✓ if Public |
//! | Grace | ✓, with a warning line | ✓ | ✓ if Public |
//! | Suspended | ✗ named reason | ✓ | ✗ |
//! | Retention | ✗ named reason | ✓ | ✗ |
//!
//! 1. **Never hold data hostage.** A suspended tenant can always clone their
//! own repositories. Refusing that is the one thing that turns a lapsed
//! customer into a complaint, and it costs nothing — clones are cheap next
//! to pushes.
//! 2. **Refuse before the bytes, not after.** The check is in
//! `authorise_service` for `git-receive-pack` and LFS upload, never in a
//! `gunnar-policy` gate: a policy gate runs after the pack is indexed, and
//! that is a 1 GB upload accepted and then refused.
//!
//! Neither rule is enforced here — a store cannot enforce — but they are
//! written here because this is the type every enforcer reads.
//!
//! # Why the state is STORED and not derived
//!
//! The clock does not move a fact. `state` is monetize's verdict at the time
//! the fact was signed; gunnar does not know the plan's grace or retention day
//! counts and must keep serving on the fact it holds when monetize is
//! unreachable. Ladder transitions arrive as new facts, each one signed.
use std::collections::BTreeMap;
use std::sync::Arc;
use crate::control::ControlStore;
use crate::doc::{impl_doc, DocRef, ShardedMap};
use crate::error::Result;
/// **The name the entitlement document is stored under** in the control store.
pub const ENTITLEMENT_REF: &str = "refs/gunnar/entitlements";
/// The ladder (the metered plan). One variant per
/// `monetize_product::State` and per `gunnar.v1.EntitlementState`, spelled
/// with the same names so a fact round-trips through all three without a
/// translation table anybody could get wrong.
#[derive(
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, serde::Serialize, serde::Deserialize,
)]
pub enum EntitlementState {
/// The free tier. Also what an unknown namespace is: never a refusal.
Free,
/// A paid plan inside its term.
Paid,
/// Paid until a date that has passed, inside the plan's grace window:
/// everything still works, and a push carries a warning line.
Grace,
/// Grace is over. Writes refused by name; reads never.
Suspended,
/// Suspended past the plan's retention window: still readable, the data
/// is kept for export, and the next step is the ordinary soft-delete.
Retention,
}
impl EntitlementState {
/// The variant name, which is also what the signed canonical JSON carries.
pub fn name(self) -> &'static str {
match self {
EntitlementState::Free => "Free",
EntitlementState::Paid => "Paid",
EntitlementState::Grace => "Grace",
EntitlementState::Suspended => "Suspended",
EntitlementState::Retention => "Retention",
}
}
/// May this namespace grow its data (push, LFS upload)?
pub fn allows_write(self) -> bool {
!matches!(
self,
EntitlementState::Suspended | EntitlementState::Retention
)
}
/// May a stranger read this namespace's public repositories? The same
/// two states close it: a lapsed account's public surface is the thing
/// that costs the provider and earns nothing.
pub fn allows_anonymous_read(self) -> bool {
self.allows_write()
}
}
impl std::fmt::Display for EntitlementState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.name())
}
}
/// One accepted fact about one namespace, exactly as monetize signed it plus
/// when this server accepted it.
///
/// The seven signed fields are field for field
/// `monetize_product::EntitlementFact` (`namespace` = its `tenant`), and they
/// are stored **unchanged**, signature included, so the fact can be re-verified
/// from the document alone. Nothing here is re-encoded on the way in: a store
/// that "normalised" a fact would be forging one.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Entitlement {
/// The tenant, as the catalog names it (`team/sub`).
pub namespace: String,
/// The plan's name — the service provider's word (`free`, `10gb`, …).
pub plan: String,
/// Where the plan stands: free, paid, in grace, or lapsed.
pub state: EntitlementState,
/// `None` is the free tier, which never expires — not "unknown".
pub paid_until_unix_ms: Option<u64>,
/// Per-meter caps gunnar enforces: `pack_bytes` is the pack quota,
/// `cache_bytes` becomes `Namespace::explode_budget_bytes`. Keys are the
/// meter names `Usage.Read` reports. Absent = the server's default.
#[serde(default)]
pub caps: BTreeMap<String, u64>,
/// `operator` | `payment:<vendor>:<reference>`. Appended verbatim to the
/// attestation log with the fact.
pub source: String,
/// Ed25519 (64 bytes) by monetize-server's key over the canonical JSON of
/// the six fields above. Verified by `monetize_embed::signing::verify_fact`
/// — gunnar has no second canonical form.
pub signature: Vec<u8>,
/// **When monetize issued the fact — the high-water mark this box refuses
/// below.**
///
/// `EntitlementSlot::set` will not accept a fact for this namespace that is
/// not NEWER than what is stored here, which is what stops a captured `Paid`
/// fact being pushed back after the tenant lapsed. It is stored and not
/// merely checked in memory because a box that forgot it over a reboot would
/// accept the replay on the next boot.
///
/// `None` is a fact signed before the field existed. A fact read back from
/// a stored document is kept as-is — re-encoding a fact is forging one —
/// but `EntitlementSlot::set` REFUSES an unstamped fact on every namespace
/// (stinky row 12, 2026-10-10): accepted "until the first stamped one", a
/// captured old `Paid` fact could be replayed onto any namespace that had
/// not had a stamped fact yet.
#[serde(default)]
pub issued_unix_ms: Option<u64>,
/// Ed25519 (64 bytes) over the canonical JSON of the seven fields above
/// INCLUDING `issued_unix_ms`. Present exactly when it is. Stored so the
/// fact can be re-verified WHOLE from the document alone: without it a
/// reboot could only check the V1 half and would silently lose the guard.
#[serde(default)]
pub issued_signature: Vec<u8>,
/// When THIS server accepted the fact (its own clock). Not signed: it is
/// gunnar's fact about the fact, and it is what `List` orders by.
pub accepted_at_unix_ms: i64,
}
impl Entitlement {
/// `caps["pack_bytes"]`, if the plan sets one. `0` is "unlimited" and is
/// reported as `None` here so a caller cannot compare against it.
pub fn pack_cap(&self) -> Option<u64> {
self.caps.get("pack_bytes").copied().filter(|cap| *cap > 0)
}
/// `caps["cache_bytes"]`, if the plan sets one — the value that becomes
/// `Namespace::explode_budget_bytes` when the fact is accepted.
pub fn cache_cap(&self) -> Option<u64> {
self.caps.get("cache_bytes").copied()
}
}
/// The facts as they are stored.
#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct EntitlementDoc {
/// Keyed by namespace name. One fact per namespace: `Set` replaces.
#[serde(default)]
facts: ShardedMap<Entitlement>,
}
impl_doc!(EntitlementDoc { facts });
/// Keep, list and replace the facts. Cheap to clone; every clone reads and
/// writes the same document.
#[derive(Clone, Debug)]
pub struct EntitlementStore {
doc: DocRef<EntitlementDoc>,
}
impl EntitlementStore {
/// Open the entitlement document held in `store` (the server's control
/// store). A store that has never held one reads as empty — every
/// namespace Free — which is what a control document written before
/// entitlements existed must load as.
pub fn new(store: Arc<dyn ControlStore>) -> Result<Self> {
Ok(EntitlementStore {
doc: DocRef::new(store, ENTITLEMENT_REF)?,
})
}
/// The name the document is stored under.
pub fn ref_name(&self) -> &str {
self.doc.ref_name()
}
/// The fact for one namespace, if any was ever accepted. `None` is Free.
pub fn get(&self, namespace: &str) -> Result<Option<Entitlement>> {
self.doc.load()?.facts.get(namespace)
}
/// Every stored fact, one per namespace, in namespace order.
pub fn all(&self) -> Result<Vec<Entitlement>> {
let mut facts = self.doc.load()?.facts.values()?;
facts.sort_by(|a, b| a.namespace.cmp(&b.namespace));
Ok(facts)
}
/// Store `fact` for its namespace, replacing whatever was there. Returns
/// the fact as stored.
pub fn set(&self, fact: Entitlement) -> Result<Entitlement> {
let key = fact.namespace.clone();
self.doc.update(|doc| {
doc.facts.insert(key.clone(), fact.clone())?;
Ok(fact.clone())
})
}
/// Forget one namespace's fact, reporting whether there was one. Back to
/// Free — used by a delete of the namespace, never by a lapse: a lapse is
/// a new fact, not an absence.
pub fn remove(&self, namespace: &str) -> Result<bool> {
let key = namespace.to_owned();
self.doc.update(|doc| Ok(doc.facts.remove(&key)?.is_some()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::control::RedbControlStore;
fn fact(ns: &str, state: EntitlementState) -> Entitlement {
Entitlement {
namespace: ns.into(),
plan: "10gb".into(),
state,
paid_until_unix_ms: Some(1_790_812_800_000),
caps: BTreeMap::from([("pack_bytes".to_string(), 10 << 30)]),
source: "payment:invoice:ocr-1".into(),
signature: vec![7; 64],
issued_unix_ms: Some(1_789_000_000_000),
issued_signature: vec![8; 64],
accepted_at_unix_ms: 1,
}
}
#[test]
fn a_fresh_store_is_empty_and_every_namespace_is_free() {
let store =
EntitlementStore::new(Arc::new(RedbControlStore::in_memory().unwrap())).unwrap();
assert!(store.all().unwrap().is_empty());
assert_eq!(store.get("anyone").unwrap(), None);
}
#[test]
fn set_replaces_per_namespace_and_survives_a_reopen() {
let backing: Arc<dyn ControlStore> = Arc::new(RedbControlStore::in_memory().unwrap());
let store = EntitlementStore::new(Arc::clone(&backing)).unwrap();
store.set(fact("a", EntitlementState::Paid)).unwrap();
store.set(fact("b", EntitlementState::Paid)).unwrap();
store.set(fact("a", EntitlementState::Suspended)).unwrap();
// A second handle over the same backing store: what a reboot sees.
let reopened = EntitlementStore::new(backing).unwrap();
let all = reopened.all().unwrap();
assert_eq!(all.len(), 2, "one fact per namespace: {all:?}");
assert_eq!(
all[0].state,
EntitlementState::Suspended,
"the LATEST fact for a"
);
assert_eq!(all[1].state, EntitlementState::Paid);
assert_eq!(
reopened.get("a").unwrap().unwrap().signature,
vec![7; 64],
"the signature is stored untouched"
);
assert!(reopened.remove("a").unwrap());
assert!(!reopened.remove("a").unwrap());
assert_eq!(reopened.get("a").unwrap(), None);
}
#[test]
fn the_ladder_closes_writes_and_anonymous_reads_in_the_same_two_states() {
use EntitlementState::*;
for (state, write) in [
(Free, true),
(Paid, true),
(Grace, true),
(Suspended, false),
(Retention, false),
] {
assert_eq!(state.allows_write(), write, "{state}");
assert_eq!(state.allows_anonymous_read(), write, "{state}");
}
assert_eq!(Suspended.name(), "Suspended");
let zero_cap = Entitlement {
caps: BTreeMap::from([("pack_bytes".to_string(), 0)]),
..fact("z", Free)
};
assert_eq!(
zero_cap.pack_cap(),
None,
"0 is unlimited, not a cap of nothing"
);
assert_eq!(fact("z", Free).pack_cap(), Some(10 << 30));
}
}