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

nordisk/gunnar-ui

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