crates/gunnar-ssh/src/plan.rs
//! **The plan gate: the front door's one question before the bytes.**
//!
//! `authorise_service` (see [`crate::server::Dispatch`]) is the ONE choke point
//! SSH and smart-HTTP share, and it is where a push is refused **before any
//! pack bytes**, which is the rule that makes this a front-door check and not a
//! `gunnar-policy` gate (a gate runs after the pack is read; that is a 1 GB
//! upload accepted and then refused). This crate does not know what a plan is,
//! what monetize is, or how a fact is signed; it asks the [`PlanGate`]s the
//! server hands in and does what the verdict says.
//!
//! # Two implementations, one chain (decided 2026-09-04)
//!
//! > two implementations: 1) the free lunch, basically checking disk has size
//! > before a new repo is created; 2) the monetize-bound one
//!
//! 1. [`crate::headroom::DiskHeadroomGate`] — **always compiled, no monetize
//! dependency.** Refuses a push and a `Repos.Create` when the store root's
//! filesystem would drop under a floor. The community binary has this one
//! link.
//! 2. `gunnar-server::entitlement::EntitlementSlot` (`metered` only) — the
//! ladder: Suspended, Retention, the pack quota. The decision is
//! `monetize_embed::EntitlementCache`'s, one atomic load and a map lookup.
//!
//! [`crate::Control`] holds them as a **chain**, asked in order, disk first;
//! the first `Refuse` wins and is the line the client is told. A box that is
//! full is told it is full, not that its plan lapsed — the disk is the fact
//! nobody can pay their way past. A gate that always answers `Allow` is
//! invisible to a request that only pushes valid things (measured, not asserted), which is why
//! every gate's refusal has a test that sets the condition and watches the
//! push die before a byte.
//!
//! # What is asked, and when
//!
//! | request | asked | may be refused |
//! |---|---|---|
//! | `git-receive-pack`, LFS upload | [`PlanAction::Write`], then [`PlanGate::pack_quota`] and [`PlanGate::pack_ceiling`] | Suspended, Retention, over `caps["pack_bytes"]` — before the bytes, and (receive-pack only) DURING them |
//! | anonymous `git-upload-pack` / LFS download | [`PlanAction::AnonymousRead`] | Suspended, Retention |
//! | the owner's, or any authenticated reader's, `git-upload-pack` | nothing | **never** — data is never held hostage |
//!
//! Asked only for a request the grant check already admitted, so a refusal
//! here never tells a stranger anything about a repository they could not
//! already see; and asked AFTER the standby freeze, so a standby stays a
//! standby whatever the plan says.
//!
//! # The one answer that is not a verdict
//!
//! [`PlanGate::pack_ceiling`] is the exception to "the front door decides
//! before the bytes", and it is the exception because it has to be: a pack
//! header carries an **object count, not a byte count**, so the size of a push
//! is not knowable here. The gate therefore answers with a NUMBER rather than a
//! verdict, the number rides on [`crate::ServiceRequest::pack_ceiling`], and
//! the refusal happens in `gunnar-wire`'s receive loop the moment the count
//! passes it. That is still the front door's decision — it is just carried to
//! where the bytes are.
/// What the request is about to do to the namespace's data.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PlanAction {
/// `git push`, LFS upload: anything that grows the tenant's data.
Write,
/// A stranger reading a public repository.
AnonymousRead,
}
/// The gate's answer.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum PlanVerdict {
/// Allowed, silently.
Allow,
/// Allowed, and the client is told `line` (Grace: `plan expired …; renew
/// at …`). Delivered as a sideband progress line, so `git push` prints it
/// as `remote: …` beside the report.
Warn(String),
/// Refused, with the line the client is told — it names the state, the
/// date and the URL: `plan 10gb expired 2026-10-01: writes closed; renew
/// at https://gunnar.rs/billing/team`.
Refuse(String),
}
/// **A byte ceiling for one push, and the line the client is told past it.**
///
/// The twin of `gunnar_wire::receive_pack::PackCeiling`, which is where it is
/// enforced. Two declarations of two fields rather than one shared type,
/// deliberately: this crate does not depend on `gunnar-wire` and must not start
/// — the same boundary [`crate::ServiceRequest::git_protocol`] is a raw
/// `String` for. `gunnar-server` holds both and converts, which is
/// four lines at one seam (`transport.rs`) rather than a dependency edge
/// between two crates that have stayed apart on purpose. There is no logic here to duplicate: the deciding
/// is in the gate below, the enforcing is in the receive loop, and this is the
/// number travelling between them.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct PackCeiling {
/// Packfile bytes past which the transfer is aborted mid-stream. A push of
/// exactly this many bytes lands.
pub bytes: u64,
/// What the client is told when it trips — a whole refusal, naming the
/// cap and where to pay, the same shape [`PlanVerdict::Refuse`] carries,
/// because a tenant cut off mid-push must not be left reading "the pack is
/// malformed".
pub refusal: String,
}
/// One link of the front door's chain: "may this namespace do this, right now".
///
/// **Nanoseconds, no I/O, no lock held across I/O.** `verdict` is on the path
/// of every push and every anonymous clone; an implementation that reached a
/// network or a disk here would put a payment provider's uptime in front of
/// `git push`, which is the outage the metered plan exists to forbid.
/// `pack_quota` may read the disk (it is the namespace's byte count, or the
/// filesystem's free space), and is asked only for a write that grows data.
pub trait PlanGate: Send + Sync {
/// The ladder's answer for `namespace` and `action`. An unknown namespace
/// is Free, which is `Allow`.
fn verdict(&self, namespace: &str, action: PlanAction) -> PlanVerdict;
/// **Is the namespace already at its `caps["pack_bytes"]`?** `Refuse`
/// names the cap, what is held and the URL; `Allow` for a namespace with
/// no cap or room under it. Asked before a single pack byte is read, so a
/// tenant at the ceiling learns it from the first line and not after the
/// upload.
fn pack_quota(&self, namespace: &str) -> PlanVerdict;
/// **How many packfile bytes may this namespace still receive, right
/// now?** `None` — the default, and every gate's answer until one has a
/// reason — means no ceiling, and the pack is read to its end whatever its
/// size.
///
/// # The hole this closes, and why `pack_quota` could not
///
/// A pack header carries an **object count, not a byte count**, so the
/// size of a push is unknown at the moment it is authorised.
/// [`PlanGate::pack_quota`] therefore answers about bytes ALREADY held and
/// `gunnar_policy::gates::limits` answers about a pack ALREADY received —
/// and a tenant one byte under a 10 GiB cap could send 400 GiB between
/// them, because nothing in the receive loop was counting. This is the
/// number that loop counts against; `gunnar-wire` cuts the stream past it
/// and tells the client [`PackCeiling::refusal`].
///
/// Asked at the same moment `pack_quota` is — a write that grows the
/// tenant's data, after the grant check — and the **smallest** answer in
/// the chain wins, which is the byte-wise spelling of the chain's own
/// first-`Refuse`-wins rule.
///
/// Cheap for the same reason `pack_quota` is allowed to be dear: it is
/// asked once per push, not once per byte.
fn pack_ceiling(&self, _namespace: &str) -> Option<PackCeiling> {
None
}
/// **How many LFS bytes may this namespace still receive?** The same
/// question [`PlanGate::pack_ceiling`] asks about a pack, one wire over, and
/// `None` means the same thing: no cap, take what arrives.
///
/// # Why the pack ceiling could not cover this
///
/// A pack byte and an LFS byte land on the same filesystem and are billed
/// at the same rate, but they are different METERS — `Usage.Read` reports
/// `pack_bytes` and `lfs_bytes` separately and a signed fact caps them
/// separately — and they arrive down different code paths.
/// `gunnar_wire::receive_pack` counts pack bytes; the LFS session never sees
/// that loop. Until this existed, `pack_ceiling` was computed for an LFS
/// upload (the chain in `Dispatch::ask_plan` has always asked for one) and
/// then **dropped on the floor**, so a tenant at their git ceiling could put
/// 8 GiB at a time into LFS for as long as the operator's disk held out.
///
/// # No slack, unlike the pack ceiling
///
/// `pack_ceiling` is generous because a pack's wire bytes and its stored
/// bytes are different numbers nobody knows in advance. An LFS object is
/// stored raw and verified against a size declared up front, so the two are
/// the same number and the answer here is exact. See
/// [`gunnar_lfs::Allowance`].
fn lfs_allowance(&self, _namespace: &str) -> Option<gunnar_lfs::Allowance> {
None
}
/// **Does this gate also bind the primary's replication push?** A
/// `Machine` principal is how a primary authenticates to its standby, and
/// by default a gate is NOT asked about it: replication is the primary's
/// copy arriving, not a tenant growing their data, and a plan that refused
/// it would make a lapsed tenant's standby diverge from the primary. The
/// disk gate answers `true`: a full standby must say so before the bytes,
/// the same as a full primary — there is no plan to exempt a disk from.
fn applies_to_machine(&self) -> bool {
false
}
/// How this gate names itself in `Control`'s `Debug` and in the boot log.
fn name(&self) -> &'static str;
}