crates/gunnar-ui-server/proto/gunnar/v1/usage.proto
syntax = "proto3";
// ─────────────────────────────────────────────────────────────────────────────
// `Usage`: per-namespace meters. OPEN SOURCE, ALWAYS ON — a self-hoster wants
// the numbers too, and counting happens inside gunnar whether or not anything
// bills from them. This file is the contract monetize's gunnar plugin codes
// against (`monetize/products/gunnar/proto/expected-gunnar-entitlement.proto`,
// the `Usage` half, verbatim) — a drift here is a compile error on that side.
//
// What each meter COUNTS, file by file, on the znippy engine (the appliance's):
//
// <git_dir>/gunnar-git.znippy/objects.pack, .pack.journal pack_bytes
// <git_dir>/gunnar-git.znippy/objects.index|tail|graph pack_bytes (the
// index and the derived tables — re-derivable, but
// "hot tier minus the exploded cache" is the contract
// and the number monetize's own fallback sum reaches)
// <git_dir>/gunnar-git.znippy/refs.log, repository.znippy pack_bytes
// <git_dir>/OBJECT_FORMAT and any other file pack_bytes
// <git_dir>/gunnar-git.znippy/objects.exploded* cache_bytes
// <git_dir>/lfs/** lfs_bytes
// a `<repo>.git.tombstone` beside a soft-deleted directory tombstoned_bytes
// (the WHOLE directory; it has no catalog row, so it
// is in none of the meters above)
//
// On the gix engine there is no exploded tier: `<git_dir>/objects/**` is
// pack_bytes and cache_bytes is 0. `cold_bytes` is 0 on both — one archive per
// repository has no separate cold tier to report, and 0 is the truth of
// what is measured rather than a stand-in. `objects` counts the indexed
// objects of stores that are OPEN in this process: that is the RAM being paid
// for (~382 B each, measured); a closed store holds none, so
// it reports 0 rather than a number nobody is paying.
// ─────────────────────────────────────────────────────────────────────────────
package gunnar.v1;
import "gunnar/v1/common.proto";
// Per-namespace meters. The names are the contract `products/gunnar` declares
// in `GunnarProduct::meters()`; a meter gunnar cannot count yet is simply
// absent, never zero-filled.
message NamespaceUsage {
string namespace = 1;
// Objects on disk that are NOT re-derivable: hot tier minus the exploded
// cache, plus the cold (znippy) tier. What gunnar.rs sells.
uint64 pack_bytes = 2;
// The exploded read cache (`RepoStorage.explode_bytes` summed).
uint64 cache_bytes = 3;
// Cold tier only (`RepoStorage.cold_bytes` summed).
uint64 cold_bytes = 4;
// LFS objects: everything under `<git_dir>/lfs/`, which is where
// `gunnar_lfs::FsLfsStore` puts them — committed objects under
// `lfs/objects/` AND uploads still in flight under `lfs/incomplete/`,
// because both occupy the tenant's disk. NOT inside `pack_bytes`.
//
// This line used to read "gunnar has no LFS store yet; report 0 until it
// does". gunnar has had one since `crates/gunnar-lfs` landed and the meter
// has summed it since `84a7ab45`; the sentence outlived the fact and was
// still being read as a contract on the monetize side (`products/gunnar`
// says "0 until gunnar has an LFS store"). Corrected 2026-09-05, against
// `gunnar-server/tests/usage_meters.rs`, which puts real objects in a real
// store and asserts the byte total.
uint64 lfs_bytes = 5;
// Soft-deleted, not yet purged, still charged (`TombstonedRepo.bytes`).
uint64 tombstoned_bytes = 6;
// Live repositories in the namespace.
uint64 repos = 7;
// Indexed objects (loose + cold) across the namespace: RAM per open store is
// ~382 B × this (measured).
uint64 objects = 8;
int64 measured_at_unix_ms = 9;
// ─── FLOW: what MOVED today, not what is held ─────────────────────────────
//
// Every meter above is a STOCK meter: it answers *how much is on the disk
// right now*, and it reads exactly the same for a tenant who pushed this
// morning and a tenant who last touched this server in March. The fields
// below are the other kind. They answer *what moved*, and they are what
// separates a customer who is using the product from one who is silent and
// from one who is hitting walls.
//
// **TODAY means the current UTC day**, from midnight, and the counters
// reset at that boundary rather than sliding. A sliding window would need
// the day's bytes kept to the millisecond forever; a UTC day is what an
// invoice, a support ticket and a graph all already mean by "today".
//
// **They ride on `Usage.Read` deliberately**, as ordinary meters and not on
// a call of their own, so that every consumer gunnar already has — the
// console's usage page, monetize's poller, `gunnar control usage` — sees
// them without learning a new verb. `Usage.Activity` is the SERIES; this is
// the glance.
//
// **Not in `Allowance`, and never enforced.** Nothing on this box refuses a
// push because of a number here, so none of them has a cap field and
// `Entitlement.Set` refuses a fact that tries to cap one, by name. In
// monetize's vocabulary they are all ENFORCEMENT_MEASURED, permanently:
// promoting one would mean building a refusal first.
//
// A gunnar older than this field answers 0 for all of them. That zero is
// indistinguishable from a genuinely quiet day, which is why `Usage.Activity`
// exists and answers with the days it actually holds — a caller that needs
// to tell "no traffic" from "not counted" asks for the series.
// Wire bytes ACCEPTED off the wire today: receive-pack, and the client half
// of every other conversation. Counted once, by the transport's own metered
// reader.
uint64 bytes_in_today = 10;
// Wire bytes SERVED today: upload-pack, the ref advertisement, and the
// archive stream. Counted once, by the transport's own metered writer.
uint64 bytes_out_today = 11;
// receive-pack sessions that finished without an error today.
uint64 pushes_today = 12;
// upload-pack sessions today, clones included. The bare advertisement
// (`git ls-remote`, `GET /info/refs`) is NOT one: it reads the ref list and
// asks for no objects, and counting it would make a monitoring probe look
// like a customer.
uint64 fetches_today = 13;
// Transfers turned away today, whatever the reason. The breakdown is on
// `DayActivity`; one number here, because a console showing a usage row
// needs "is this tenant hitting walls" in one field.
uint64 refused_today = 14;
// The last transfer that put bytes IN, in Unix milliseconds — **not
// restricted to today**. This is the "last seen" an operator reads to decide
// whether a tenant has gone quiet, so it looks back over the whole kept
// window and is 0 only when the namespace has never been pushed to inside
// it.
int64 last_push_unix_ms = 15;
// The last transfer that took bytes OUT. See `last_push_unix_ms`.
int64 last_fetch_unix_ms = 16;
}
// **One namespace's flow for one UTC day** — the row `Usage.Activity` answers
// with, one per day that had traffic.
//
// A day with no traffic has NO ROW. It is not returned as a row of zeroes,
// because a day that was never written and a day on which nothing happened are
// the same fact and the caller already knows which days it asked for. That is
// the opposite of the stock meters, where a missing reading and a zero mean
// different things — a transfer either occurred or it did not.
message DayActivity {
// Midnight UTC of the day, in Unix milliseconds. The key, and the x axis.
int64 day_unix_ms = 1;
uint64 bytes_in = 2;
uint64 bytes_out = 3;
uint64 pushes = 4;
uint64 fetches = 5;
// LFS object bytes, as the LFS session itself counted them. These bytes are
// ALSO in `bytes_in` / `bytes_out` as wire bytes: this is the applied figure,
// that is the wire figure, and they are different numbers about the same
// transfer rather than one of them being a subset to subtract.
uint64 lfs_bytes_in = 6;
uint64 lfs_bytes_out = 7;
// Transfers turned away that day, whatever the reason.
uint64 refused = 8;
// **The breakdown**, by the three words a tenant can act on differently:
//
// `admission` — the box was full. Nothing about the tenant is wrong; the
// server said "not now". The gate's own six ceilings stay on
// the gate, where they are an operator's question about the
// BOX rather than a tenant's about their service.
// `cap` — the plan refused before a byte: the pack quota, the LFS
// allowance. The one an invoice can fix.
// `policy` — the push gate refused: a protected ref, a missing review, a
// signature that did not verify. Neither money nor capacity.
//
// A reason with no refusals that day is ABSENT, never 0.
map<string, uint64> refused_by_reason = 9;
// **A principal that authenticated and then held no grant on what it asked
// for.** The other half of that sentence — a key that resolves to NOBODY —
// is deliberately not here: it happens before any repository is named, so it
// belongs to no namespace and is counted server-wide by `Admin.Stats`
// instead. Charging it to a tenant would invent an attribution nothing
// measured.
uint64 auth_failures = 10;
// The last push and fetch OF THAT DAY, in Unix milliseconds; 0 when the day
// had none. Kept per day rather than only on the latest row so a series can
// answer *when in that week did they stop*, not only *have they stopped*.
int64 last_push_unix_ms = 11;
int64 last_fetch_unix_ms = 12;
// **How many distinct principals were seen that day.** An EXACT count, not a
// sketch: at the scale a namespace has, a set of fingerprints costs less than
// a HyperLogLog and answers a strictly harder question. It is bounded, and
// `distinct_principals_capped` is how a count that stopped growing says so
// rather than quietly flattening.
uint64 distinct_principals = 13;
// `distinct_principals` is a FLOOR, not a total: the day had more principals
// than the server counts by name (10 000). Never true on any real namespace;
// it exists so that if it ever is, the number says so.
bool distinct_principals_capped = 14;
}
service Usage {
rpc Read(ReadUsageRequest) returns (ReadUsageResponse);
rpc ReadAll(ReadAllUsageRequest) returns (ReadAllUsageResponse);
// **The SERIES: one row per UTC day this namespace moved anything.**
//
// `Read` answers *how much is held, and what moved today*. Only a series
// answers *is this tenant using the product, has it gone quiet, and since
// when* — and those are the questions an operator has about a customer,
// which no stock meter has ever been able to answer.
//
// The grant is `Read` **on the namespace**, exactly as `Usage.Read`: a
// tenant may read their own activity, and nothing here needs the server-wide
// grant `ReadAll` takes. Activity is a fact about one namespace's own
// traffic.
//
// Rows come back OLDEST FIRST, and a day with no traffic is absent rather
// than zero-filled. A `since` older than the kept window is not an error —
// it returns what is left, and `retained_days` says where the window
// actually starts.
rpc Activity(ActivityRequest) returns (ActivityResponse);
// **The admission plan for this namespace**: usage over the rolling window,
// the budget, and per kind of work the class it would get, the learnt
// estimate and the sentence saying why. Same grant as `Activity`: `Read` on
// the namespace. See `AdmitPlanRequest`.
rpc AdmitPlan(AdmitPlanRequest) returns (AdmitPlanResponse);
}
message ActivityRequest {
string namespace = 1;
// The window, in Unix milliseconds, inclusive at both ends and resolved to
// whole UTC days. `0` for `since` means the whole kept window; `0` for
// `until` means now. A window that runs backwards returns nothing rather
// than an error: it is an empty question, and an empty answer is what it
// deserves.
int64 since_unix_ms = 2;
int64 until_unix_ms = 3;
}
message ActivityResponse {
// Oldest first. Days with no traffic are absent; see `DayActivity`.
repeated DayActivity days = 1;
// **How many days this server keeps** (400). The caller needs it to tell an
// empty answer that means "nothing happened" from one that means "you asked
// about a time this box no longer remembers".
int64 retained_days = 2;
}
message ReadUsageRequest {
string namespace = 1;
}
message ReadUsageResponse {
NamespaceUsage usage = 1;
// **What the namespace is ALLOWED**, beside what it holds — the one thing a
// tenant needs and could not ask for before 2026-09-05 (`Entitlement.Get`
// is server-wide `Read`; this rides on the namespace's own `Read`). Always
// present; each cap inside it is absent when there is none. Not on
// `ReadAll`: an allowance is one namespace's own business.
Allowance allowance = 2;
// **What is left under each cap** (2026-10-04, the thin layer): cap − held,
// clamped at 0, on the namespace's own `Read`. ABSENT exactly when the cap
// is absent, so "uncapped" is never spelled as a number. gunnar's answer,
// so a console stops computing four different ones. Always present; each
// meter inside it is optional. Nothing here knows the BOX's free space:
// that is `Admin.Capacity`, a server-wide read.
Remaining remaining = 3;
// **The namespace's repositories, each with what it stores** (2026-10-04),
// measured by the same walk that sums `usage` — so `stored_bytes` across
// these rows is `usage.pack_bytes`. On the namespace's own `Read`: a tenant
// sees where their room went without a whole-box `Admin.StorageInfo`.
repeated RepoStored repos = 4;
}
// Per meter, what is left under the cap: field N mirrors `Allowance` field N
// and `NamespaceUsage` field N. Absent when that meter is uncapped.
message Remaining {
optional uint64 pack_bytes = 2;
optional uint64 cache_bytes = 3;
optional uint64 cold_bytes = 4;
optional uint64 lfs_bytes = 5;
optional uint64 tombstoned_bytes = 6;
optional uint64 repos = 7;
optional uint64 objects = 8;
}
// One repository's share of its namespace's meters.
message RepoStored {
RepoPath repo = 1;
// What this repository adds to `NamespaceUsage.pack_bytes`: the truth and
// its indexes (hot minus the exploded cache, plus the cold tier).
uint64 stored_bytes = 2;
// Its exploded read cache: its share of `cache_bytes`.
uint64 cache_bytes = 3;
// Its LFS objects: its share of `lfs_bytes`.
uint64 lfs_bytes = 4;
// Objects open in the store right now: its share of `objects`.
uint64 objects = 5;
}
// **Per meter, the cap this appliance holds for the namespace, in the meter's
// own unit** (bytes for the `*_bytes` meters, a count for `repos` and
// `objects`). Field N here caps field N of `NamespaceUsage`, and the names
// match, so `allowance.pack_bytes` is the ceiling on `usage.pack_bytes`.
//
// **ABSENT means uncapped, and absent is never spelled 0.** On the community
// appliance every cap is absent — it holds no caps at all, for anyone. On the
// metered appliance a cap is present when a signed entitlement fact names the
// meter in `caps` with a value above zero; a fact that does not name the
// meter, a namespace with no fact, and a `caps[meter]` of 0 (which the push
// door already reads as "unlimited") are all ABSENT here, so a reader never
// has to know that rule.
//
// **A CAP IS NOT AUTOMATICALLY A WALL, and [`Allowance.enforcement`] is where
// that is said out loud.** This paragraph used to read *"`pack_bytes` and
// `cache_bytes` are the two the door enforces today; the rest are what the fact
// declares"* — true, buried in a comment, and invisible to every reader of the
// message. A tenant whose fact carries `caps["repos"]=5` was shown a limit of
// `5` here and nothing on the box refused the sixth repository. A number a
// tenant is shown as a limit that no code enforces is a promise the product does
// not keep, so the distinction is now a FIELD and not a sentence in a comment.
//
// No price, no plan name, no date: a cap is a resource number, and a
// self-hoster reading their own disk finds exactly the same message a paying
// tenant does.
message Allowance {
// **What a cap on each meter DOES on this appliance**, one entry per meter
// this message has a field for, ALWAYS present and always for all seven —
// including the meters whose cap is absent, because "nothing caps your
// repositories, and nothing would" and "you have no repository cap yet" are
// different sentences and a console showing a usage row needs the first one
// even when there is no cap to put beside it.
//
// Field 1 rather than 9: this message numbers its caps 2-8 to mirror
// `NamespaceUsage`'s fields 2-8, and 1 was left free by that alignment. It is
// the one field here that is NOT about one meter's number, so it takes the
// one number that is not part of the mirror.
//
// Constant for a given build — it describes the CODE, not the tenant — and
// sent per read anyway, because a client that had to learn it out of band
// would be a client that can be wrong about it.
map<string, Enforcement> enforcement = 1;
optional uint64 pack_bytes = 2;
optional uint64 cache_bytes = 3;
optional uint64 cold_bytes = 4;
optional uint64 lfs_bytes = 5;
optional uint64 tombstoned_bytes = 6;
optional uint64 repos = 7;
optional uint64 objects = 8;
}
// **What a cap on a meter DOES**, in monetize's own words and with monetize's
// own three names.
//
// This is `monetize_product::Enforcement` on the wire, deliberately verbatim
// (`monetize/crates/monetize-product/src/lib.rs`, the `Enforcement` enum):
// monetize's meter table already models exactly this distinction and its console
// already renders `enforced` and `measured` differently. Inventing a second
// vocabulary for it — `is_a_wall`, `hard`/`soft`, an `enforced` string list —
// would have meant two spellings of one idea across the seam that reports
// gunnar's numbers to monetize's console, and one of them going stale.
//
// gunnar answers only MEASURED and ENFORCED. PROVISIONED is here so the
// vocabulary is monetize's whole one rather than the two-thirds gunnar happens
// to use: monetize's `cpu_millicores` and `ram_bytes` are provisioned at the
// cloud provider, they are `Scope::Product` rather than per tenant, and they
// have no field in `Allowance` at all — so a reader that maps this enum onto
// monetize's finds every variant where it expects it.
enum Enforcement {
ENFORCEMENT_UNSPECIFIED = 0;
// **Counted and reported; nothing refuses.** The number is real and is
// billed from; crossing it is not an event on this box. `cold_bytes`,
// `tombstoned_bytes`, `repos` and `objects` are all this today.
ENFORCEMENT_MEASURED = 1;
// **The appliance refuses when over**, and the tenant meets a real door with
// a sentence naming the plan and where to pay. `pack_bytes` (cut mid-stream
// by `gunnar_wire::receive_pack`), `cache_bytes` (becomes the namespace's
// `explode_budget_bytes`) and `lfs_bytes` (`gunnar_lfs::Allowance`, refused
// before the object's bytes on both wires).
ENFORCEMENT_ENFORCED = 2;
// **Bought at the cloud provider, not policed by the product.** No meter in
// this message is this; see the enum comment for why the variant exists.
ENFORCEMENT_PROVISIONED = 3;
}
message ReadAllUsageRequest {}
message ReadAllUsageResponse {
repeated NamespaceUsage usages = 1;
}
// **What admission would do with this namespace's next transfer, and why** —
// `gunnar control admit plan`, and the console's admission panel.
//
// The admission gate asks `edda_admit::Admit::plan` for every keyed transfer
// on a build with the `admit-share` feature: rolling-window bytes moved
// (this ledger's `bytes_in + bytes_out`, summed over `window_days` UTC days,
// today included) against the configured budget. Below it the namespace gets
// the ordinary per-principal share (`Full`); at or above it, the anonymous
// ceiling (`Reduced`). Nothing is ever queued: a class is a share of slots and
// a refusal names its reason.
//
// A build WITHOUT the feature answers too, with `policy = "pass-through"`,
// no limit, and every kind `Full` — which is the truth about that build, and
// the usage figure is the same ledger's either way.
//
// The grant is `Read` **on the namespace**, as `Usage.Read` and
// `Usage.Activity`: a tenant may read their own budget.
message AdmitPlanRequest {
string namespace = 1;
}
message AdmitPlanResponse {
// `pass-through` or `usage-share` — the `edda_admit::Admit::policy` in force.
string policy = 1;
// The rolling window, in UTC days, today included.
uint32 window_days = 2;
// Bytes moved (in + out) over the window.
uint64 used_bytes = 3;
// The budget per window at the Full share. ABSENT when there is none.
optional uint64 limit_bytes = 4;
// What is left of it. ABSENT when there is no limit.
optional uint64 budget_left_bytes = 5;
// One plan per kind of work this server admits, clone first.
repeated AdmitPlanKind kinds = 6;
}
message AdmitPlanKind {
// `clone`, `fetch`, `push`, `request` (ls-remote and gc).
string kind = 1;
// `Full`, `Reduced`, `Floor` or `Queued`.
string admit_class = 2;
// The plan's sentence, e.g. "100 GB/30d used 63 GB; this clone ~1.2 GB; Full".
string why = 3;
// The learnt estimate of the bytes it moves (in + out + upstream); 0 = no
// estimate yet.
uint64 estimate_bytes = 4;
// The learnt estimate of its CPU time. 0 today: gunnar does not measure it.
uint64 estimate_cpu_ms = 5;
// How many finished transfers of this kind the estimate learnt from since
// the server started. 0 = the estimate is a prior or absent.
uint64 samples = 6;
}