crates/gunnar-ui-server/proto/gunnar/v1/admin.proto
syntax = "proto3";
package gunnar.v1;
import "gunnar/v1/common.proto";
// Server introspection, over the gRPC control plane.
service Admin {
rpc Health(HealthRequest) returns (HealthResponse);
rpc Stats(StatsRequest) returns (StatsResponse);
rpc StorageInfo(StorageInfoRequest) returns (StorageInfoResponse);
// Walk the provenance chain backwards from a line or a rev —
// `gunnar verify`, served. See the message set at the end of this file.
rpc VerifyChain(VerifyChainRequest) returns (VerifyChainResponse);
// **"If a tenant's cap rose to N bytes, could you serve it?"** — the
// question a seller must ask BEFORE the money moves. See the message set
// below for why that is not the same question as "how much is free".
rpc Capacity(CapacityRequest) returns (CapacityResponse);
// ── the runtime data-set growth (additive, 2026-09-14; see the tail of
// this file). Start / Resume: gunnar-admin. Status: any credential with
// server-wide Read — and the SAME answer on `Bootstrap.GrowStatus` with
// no credential at all.
rpc GrowStart(GrowStartRequest) returns (GrowStatusResponse);
rpc GrowStatus(GrowStatusRequest) returns (GrowStatusResponse);
rpc GrowResume(GrowResumeRequest) returns (GrowStatusResponse);
// **"Which CA does this box mint with?"** (additive, 2026-09-17) — the
// question nobody outside a running appliance could ask.
//
// `gunnar issuer show` answers it from the DISK, and the appliance has no
// shell. The issuer certificate was otherwise returned by exactly one RPC,
// `Bootstrap.Enroll`, and that window closes for good on the first enrol —
// so on a RESTORED box, the one where "is this still the CA my clients
// pinned?" is the question that matters, there was no way to find out at
// all. This is that read, and nothing more: it reaches the PKI document
// through `ServerStores::pki_readonly()`, which installs a secret store
// that refuses every call, so this verb is structurally incapable of
// minting, rotating or touching the private key.
//
// Server-wide Read, like `Health` and `StorageInfo`. See `auth::METHODS`.
rpc IssuerInfo(IssuerInfoRequest) returns (IssuerInfoResponse);
// **"Is the record of what happened here still complete?"** (additive,
// 2026-09-21, lane T11).
//
// The control plane has written a tamper-evident audit trail since GUN-044
// and there has never been a way to READ it except as root on the box —
// which an appliance does not have. So the one question the trail exists to
// answer could not be asked of a running server at all, and in particular
// could not be asked of a RESTORED one, where it is the whole question.
//
// It answers the chain's shape, never its contents: how many entries, the
// tip hash (which commits to the whole history), whether the chain verifies,
// and — given `at_seq` — the hash AT that sequence. That last field is what
// makes a restore provable: a box restored from the twin continues appending,
// so its tip MOVES; what must not have changed is the hash the primary
// recorded at its own last sequence. Comparing that is a prefix proof over
// the whole trail, in one string.
//
// No entry bodies: who did what is a read of the file, and this verb exists
// to answer completeness, not to serve the log over the wire to every
// credential with server-wide Read.
//
// Server-wide Read, like `Health` and `IssuerInfo`. See `auth::METHODS`.
rpc AuditTrail(AuditTrailRequest) returns (AuditTrailResponse);
// **This box's WireGuard tunnel address, for a box PAST its bootstrap**
// (additive, 2026-09-21, T17). `Bootstrap.AssignTunnelAddress` writes only
// while the box is fresh; an already-enrolled box installed from a medium
// that carried no address has no other door. Server-wide ADMIN: the tunnel
// address decides what the hub routes to this box. Same semantics as the
// bootstrap door: empty `address` = a read; the same address again is a
// no-op; a different one is refused (FAILED_PRECONDITION), never re-pointed.
rpc SetTunnelAddress(AssignTunnelAddressRequest) returns (AssignTunnelAddressResponse);
// **Sign a twin's replication certificate request** (additive, 2026-09-21,
// lane T9). Server-wide ADMIN: it mints a credential into the stream that
// carries every sealed secret. Refused unless this box serves a mutual
// replication listener and `principal` is a member of group `twin`. Registers
// the certificate to `principal` with its expiry, and answers it with the
// issuer to pin and the port to dial.
rpc IssueTwinCertificate(IssueTwinCertificateRequest) returns (IssueTwinCertificateResponse);
// **"Which boxes is this estate, as THIS gunnar knows them?"** (additive,
// 2026-10-04, owner: the console's Machine card draws it, and the console
// decides nothing about it). One row per box: this box first (primary or
// twin), then every box it has EVIDENCE of — the pairing feed's peers, the
// twins whose `Mirror.LogHead` polls it records, its primary (on a twin),
// the replication standbys it is configured with, the consoles that call
// it, and the monetize it registers its plugin with. Each row carries its
// own live state and the sentence that says how gunnar knows. A solo gunnar
// is one row; a front and a server two; with a twin three. Server-wide Read,
// like `Health`: the rows name addresses, never a secret.
rpc Topology(TopologyRequest) returns (TopologyResponse);
// **A generic server setting** (additive, 2026-10-26, owner: "stored as a
// generic server setting"). Bytes under a NAME the client chooses, namespaced
// by it (a console's own switches, `console.<switch>`); the server knows
// no setting name and gives no value a meaning. Server-wide ADMIN, both.
// Bounded: a name of 1 to 128 bytes of [a-z0-9._-], a value of at most 4096
// bytes, at most 256 names; a refusal (INVALID_ARGUMENT) names the bound.
// Every Set is audited: who, the name, and old -> new.
rpc GetSetting(GetSettingRequest) returns (GetSettingResponse);
rpc SetSetting(SetSettingRequest) returns (SetSettingResponse);
}
message GetSettingRequest {
string name = 1;
}
message GetSettingResponse {
// False where nothing is stored under the name (`value` is then empty).
bool present = 1;
bytes value = 2;
}
message SetSettingRequest {
string name = 1;
bytes value = 2;
}
message SetSettingResponse {
// Whether the name held a value before, and the value it replaced.
bool had = 1;
bytes previous = 2;
}
message AssignTunnelAddressRequest {
// `a.b.c.d/prefix`, the site's real prefix; empty = only answer.
string address = 1;
}
message AssignTunnelAddressResponse {
// This box's WireGuard public key, 44 characters of base64: the hub's [Peer].
string wg_public_key = 1;
// The address the box holds now, `a.b.c.d/prefix`; empty = none yet.
string address = 2;
// True iff THIS call wrote the address.
bool changed = 3;
// True when `wg0` is up with the address in this process.
bool tunnel_up = 4;
string detail = 5;
}
message HealthRequest {}
message HealthResponse {
bool serving = 1;
// "gunnar/<version>"
string agent = 2;
// **The CRATE version, from Cargo.toml.** It moves on a release and not on a
// build, so two boxes running very different code report the same string —
// which is what it is for, and also why it must not be read as "what is
// running here". `build_ref` below is that answer.
string version = 3;
uint64 uptime_seconds = 4;
// ⚠ TRUE on any ordinary server since the gate landed. This said
// "FALSE in v1: the gRPC control plane is unauthenticated by decision" long
// after that stopped being so, and the stale sentence did real damage: on
// 2026-08-31 it was quoted as the corroborating authority for a report that
// the reference appliance was serving an anonymous admin surface, which sent a fleet
// after a hole that was not there. It is reported on the wire so an operator
// can SEE the answer rather than remember it — so it has to be the answer.
//
// This is the LISTENER's mode, not the build's: `AUTHENTICATION_ENABLED` is a
// `const bool = true` with no cfg, no feature and no env var behind it, while
// this field carries `ServerConfig::authentication_enabled()`. The only way
// it reads false is a listener built with
// `insecure_allow_all_for_local_development()`, which no production path
// calls and which no config file or environment variable can reach.
bool grpc_authentication_enabled = 5;
// The address this gRPC listener is actually bound to. A non-loopback bind is
// not by itself a hole — the gate above governs all nine other services — but
// `Bootstrap` is mounted OUTSIDE the gate by design, so on a server that has
// never been enrolled a non-loopback bind offers `Bootstrap.Enroll` to
// whoever reaches the port first, and that caller becomes gunnar-admin.
string grpc_bind = 6;
// The SSH listener, which IS authenticated (publickey, always) and is a
// SEPARATE listener. Git push/fetch never traverse gRPC.
string ssh_bind = 7;
// ── the two security flags an operator has to be able to SEE ────────────
//
// Both are `gunnar serve` flags that default OFF for migration reasons, not
// for security ones, and both are turned on by editing a unit file and
// restarting. The only way to confirm that a restart took is to ask the
// server, and until these fields existed there was nothing to ask.
//
// Additive (2026-09-12). A client that ignores them loses nothing.
// `--grpc-tls-self`: this listener demands a client certificate its own
// issuer signed, and maps the one that got through to an ACL principal. See
// `gunnar issuer readiness` for the host-side check that says whether
// turning this on will lock anybody out.
bool grpc_mutual_tls = 8;
// `--grpc-require-bound-credentials`: the method-blind `gunnar-sshsig-v1`
// credential is refused outright. The four fields below are what makes this
// one safe to flip.
bool grpc_require_bound_credentials = 9;
// **v1 credentials this listener has SERVED since it came up.**
//
// The number the flag decision rests on: while it is above zero, turning
// `--grpc-require-bound-credentials` on breaks a client that works today.
// Read it beside `uptime_seconds` — a zero on a server that has been up for
// ten seconds is not evidence, and the two are in this one message so that
// they cannot be read apart.
uint64 unbound_credentials_accepted = 10;
// v1 credentials REFUSED by the flag. Only ever above zero on a listener
// that already has the flag on, where it names the clients the flip broke.
// Counted at presentation, before the signature is checked, so it includes
// junk as well as honest un-updated clients.
uint64 unbound_credentials_refused = 11;
// When the last v1 credential of either kind arrived. 0 if none has.
int64 unbound_credential_last_seen_unix_ms = 12;
// **Who.** One row per (fingerprint, method) pair, so an operator chasing
// the last un-updated client knows what it does and not only that it
// exists. The roster is bounded and the counters above are not: past the
// bound `unbound_credential_callers_dropped` rises and the totals still
// move, so a full roster can never turn a non-zero count into an empty
// list that reads as "nobody".
repeated UnboundCredential unbound_credential_callers = 13;
uint64 unbound_credential_callers_dropped = 14;
// ── the clock (additive, 2026-09-14) ─────────────────────────────────────
//
// This server's own wall clock when it answered, and what its clock sync
// concluded at boot and most recently. The credential gate refuses a caller
// whose clock is more than five minutes from this one; these fields are how
// an operator sees which side is wrong without a console. The last report is
// also on `Bootstrap.Status`, which needs no credential — see there.
int64 server_unix_ms = 15;
ClockSync clock_boot = 16;
ClockSync clock_last = 17;
// Skew-refusal syncs the one-a-minute rate limit dropped since boot. A large
// number is somebody sending stale credentials, honest or not.
uint64 clock_skew_syncs_suppressed = 18;
// ── the data set's grow journal (additive, 2026-09-14) ────────────────
// What the boot stack did about `/data` at the last boot, per member and
// per step — the operator's view of `DATA-SET-GROWTH-DESIGN.md` §8.
// Absent when this box's boot stack keeps no journal.
GrowState grow_state = 19;
// ── the dual heartbeat (additive, 2026-09-15) ─────────────────────────
// On a PRIMARY: one row per twin — every member of group `twin`, and every
// caller that reported as a twin on `Mirror.LogHead` since this process
// started. A member never heard from reads `silent`.
repeated TwinHeartbeat twins = 20;
// On a TWIN: when its primary last answered. Absent on a primary.
PrimaryHeartbeat primary = 21;
// ── what is actually running here (additive, 2026-09-16) ──────────────
// **`git describe --tags --always --dirty`, stamped at build time** —
// `v1.4.0-228-gb86f88f`, not the `1.4.0` of `version` above.
//
// It exists because the two were confused for real: a console showed
// `version` in its header band, an operator read `1.4.0` and reasonably
// concluded the box was on the release, while the appliance was in fact 228
// commits past the tag. `gunnar control status` had printed the describe as
// `build` the whole time, so the fact was measured and simply never left the
// CLI. Every consumer that wants "which code is this" wants THIS field.
//
// Empty when the build had no git to ask (a source tarball, a vendored
// build) — empty is "not stamped", never "the same as version".
string build_ref = 22;
// ── alarms (additive, 2026-09-21, lane T3) ───────────────────────────────
//
// A state this box is IN that an operator must act on, and that no console
// reaches. MEASURED on the UpCloud machine shape (SeaBIOS, `-serial none`): a
// box restored under the wrong monetize key logs `THIS BOX IS SERVING 2
// NAMESPACE(S) WITH A SIGNED FACT AS FREE` at WARN, and on that machine the
// line goes nowhere. An alarm is that line, on the wire. Empty = none.
repeated HealthAlarm alarms = 23;
// ── the status (additive, 2026-09-22, owner ruling `monetize_vital`) ─────
//
// `serving`, or `maintenance`: the box starts and serves either way, and
// `maintenance` says an operator must act — today only a metered box with
// `--monetize-vital` (the default) whose stored entitlement facts did not
// verify at boot, until monetize pushes one that does. The alarm that says
// why is in `alarms` (`entitlement.key_mismatch`). Empty from an older box.
string status = 24;
}
// One alarm: the same `event` name the box logs, how many things it covers,
// since when (unix ms, the box's clock), and a sentence saying what to do.
message HealthAlarm {
string event = 1;
uint64 count = 2;
int64 since_unix_ms = 3;
string detail = 4;
}
// **What a primary knows about one twin** — measured from the twin's own
// `Mirror.LogHead` polls, each of which carries a `TwinReport`.
message TwinHeartbeat {
// The twin's principal on this primary (its machine key's owner).
string principal = 1;
// Where the last poll came from (`ip:port` of the connection), empty when
// never heard from.
string address = 2;
// Wall clock of the last poll; 0 = never since this process started.
int64 last_seen_unix_ms = 3;
// `server_unix_ms - last_seen_unix_ms` at answer time; 0 when never seen.
uint64 seen_ago_ms = 4;
// What the twin had applied when it polled, and this primary's head then.
uint64 applied_seq = 5;
uint64 head_seq = 6;
// `head_seq - applied_seq` at the last poll.
uint64 lag_entries = 7;
// The twin's data volume, as the twin measured it (statvfs).
uint64 disk_total_bytes = 8;
uint64 disk_used_bytes = 9;
// `used * 1000 / total`; 0 when total is 0.
uint32 fill_permille = 10;
// `ok`, `behind` (the lag did not shrink over the last polls while above
// zero), or `silent` (no poll within the silence bound, or never).
string state = 11;
// One sentence, for a human.
string why = 12;
// The twin's own follower state word at its last poll (`following`, …).
string twin_state = 13;
// The silence bound this answer used, in milliseconds.
uint64 silent_after_ms = 14;
// **The twin's load at its last poll** (additive, 2026-10-05), as the twin
// measured it (`TwinReport`): busy and total millicores. Absent when the
// twin did not say (an older twin) — never 0.
optional uint64 cpu_millicores_busy = 15;
optional uint64 cpu_millicores_total = 16;
}
// **What a twin knows about its primary.**
message PrimaryHeartbeat {
// `twin.json`'s `primary`.
string address = 1;
// Wall clock of the last `LogHead` the primary answered; 0 = never.
int64 last_answer_unix_ms = 2;
uint64 answer_ago_ms = 3;
// The primary's head at that answer, and what this twin had applied.
uint64 primary_head_seq = 4;
uint64 applied_seq = 5;
// `ok` or `silent` (no answer within the bound).
string state = 6;
string why = 7;
uint64 silent_after_ms = 8;
}
// One client still presenting the method-blind credential (GUN-001).
message UnboundCredential {
// The `SHA256:…` the header claimed. On a `refused` row it is only a claim:
// the flag refuses before the key lookup.
string fingerprint = 1;
// The ACL principal, when the credential got far enough to name one. Empty
// on a `refused` row.
string principal_id = 2;
// `Service.Method`, as the router was about to dispatch it.
string method = 3;
int64 first_seen_unix_ms = 4;
int64 last_seen_unix_ms = 5;
uint64 count = 6;
// These presentations were refused by the flag rather than served.
bool refused = 7;
}
message StatsRequest {}
message StatsResponse {
uint64 namespaces = 1;
uint64 repos = 2;
uint64 principals = 3;
uint64 keys = 4;
uint64 grants = 5;
// The event bus. `events_dropped` is the sum over all subscribers of events
// lost to lag. It is the number that tells an operator the buffer is too
// small, and it is why the gap marker exists.
uint64 event_subscribers = 6;
uint64 events_published = 7;
uint64 events_dropped = 8;
uint64 event_buffer_capacity = 9;
// Background index work in flight (znippy engine): one row per repository
// whose pushed pack is still being resolved. Empty when the box is idle.
// Additive (2026-08-30); a client that ignores it loses nothing.
repeated IndexProgress index = 10;
// **Soft-deleted repositories, and the bytes they are still holding**
// (2026-08-30). A tombstone is storage the account is USING: a
// customer who deletes a repository and sees no change in their usage is
// looking at the retention window, not at a bug, and this is where they see
// it. `tombstoned_bytes` is not inside any other total here.
uint64 tombstoned_repos = 11;
uint64 tombstoned_bytes = 12;
// **Replication, on the wire.** The replication counters and one row per
// configured standby. Empty `standbys` with `replication_configured` false
// means nothing is configured; empty with it TRUE would be a bug. The
// feature sent nothing at all for weeks before that and nothing said so —
// these fields are how that becomes visible.
bool replication_configured = 13;
uint64 replication_synced = 14;
uint64 replication_failed = 15;
uint64 replication_enqueued = 16;
uint64 replication_bus_gaps = 17;
repeated StandbyStatus standbys = 18;
// **Refs this server holds that NO standby carries**, and the total they are
// counted against.
//
// Replication copies `refs/heads/*` and `refs/tags/*` and nothing else.
// MEASURED on the live primary 2026-08-31, during the first restore drill:
// 1292 refs across the 24 replicated repositories, **567 of them outside
// those two prefixes** — `refs/remotes/origin/*` mostly, but also
// `refs/superseded/*` and `refs/salvage/*`, which are places somebody
// parked work because it mattered. The rows above read healthy over all of
// it. A green standby means "every branch and tag is on it", and without
// these two numbers nothing on any surface said so.
uint64 replication_refs_total = 19;
uint64 replication_refs_unmirrored = 20;
// **The tenants row** (the entitlement surface): namespaces by enforced
// plan state, and the numbers a service provider's license usage is
// computed from. All zero on a build without `metered` — it holds no facts
// and counts nothing, which is the truth of that binary rather than a
// claim that every tenant is Free. `tenants_suspended` counts Suspended
// AND Retention: both are "not paying, writes closed".
uint64 tenants_paid = 21;
uint64 tenants_grace = 22;
uint64 tenants_suspended = 23;
}
// One replication standby, as `Admin.Stats` and the console band report it.
message StandbyStatus {
string name = 1;
string remote = 2;
// Repositories with work outstanding for this standby.
uint64 pending = 3;
// Consecutive failures for the job at the head of the queue; 0 after a
// success.
uint32 consecutive_failures = 4;
// The last failure since the last success, if any.
string last_error = 5;
// **0 means NEVER.** Not "just now", not "unknown" — never, and it is the
// single most important value in this message: a standby that has never
// taken a byte is a durability promise that does not exist.
int64 last_success_unix_ms = 6;
// The standby has a hole retrying will not close; `gunnar replication
// resync` is the answer.
bool needs_full_resync = 7;
// **Work is stuck AND nothing has landed for 10x the retry ceiling.**
//
// NOT "the last success is old", which is what it meant until 2026-08-31 and
// which made a healthy idle box print STALE all weekend: with nothing to
// replicate there is nothing to be late for. `verdict` below is the word.
bool stale = 8;
// **The one word for this standby's state**, decided by the server so that
// the console badge, the CLI row, the appliance band and the alarm cannot
// disagree: `NEVER`, `STALE`, `BEHIND`, `syncing` or `idle`. Upper case is an
// alarm. Empty from a server older than 2026-08-31.
string verdict = 9;
}
// One repository's post-ack index work, as the dashboard row
// `index alice/linux 34% 4.7M/13.9M resolve` shows it. Information, never a
// gate: nothing waits on it.
message IndexProgress {
RepoPath repo = 1;
uint64 pack_id = 2;
uint64 objects_done = 3;
uint64 objects_total = 4;
// Pack bytes covered by the resolved objects so far / the pack's size.
uint64 bytes_done = 5;
uint64 bytes_total = 6;
// "walk" | "resolve" | "paused" | "flush" | "fold" | "done" | "idle"
string phase = 7;
int64 started_at_unix_ms = 8;
// The journal re-queued this pack after a restart: the count started from
// zero again, the row reads `resumed 0%`.
bool resumed = 9;
uint32 percent = 10;
// Delta bases dropped for the memory ceiling and inflated again.
uint64 reinflations = 11;
// Memory-watchdog pauses so far on this pack.
uint64 pauses = 12;
}
message StorageInfoRequest {
// Empty reports server-wide totals only.
optional RepoPath repo = 1;
}
// Storage: which backend is actually behind the two traits, and how big it is.
message RepoStorage {
RepoPath repo = 1;
HashKind hash_kind = 2;
uint64 loose_object_count = 3;
// Hot tier (redb). Every push lands here, O(new objects).
uint64 hot_bytes = 4;
// Cold tier (znippy): one archive per repo, written by repack().
uint64 cold_bytes = 5;
uint64 cold_object_count = 6;
int64 last_repack_at_unix_ms = 7;
uint64 ref_count = 8;
// The exploded read cache. NOT storage: every object in it is
// re-derivable from the pack, so the server unlinks it when the budget says
// so and a later read pays ~0.5-2 ms to inflate its own delta chain again.
// `explode_bytes` is inside `hot_bytes`, which counts the whole directory.
uint64 explode_bytes = 9;
// The cap, per repository. 0 is unlimited. A bigger quota buys a bigger
// cache and therefore faster first reads; a smaller one costs re-inflation,
// never an error and never a refusal.
uint64 explode_budget_bytes = 10;
// Bytes the budget has already reclaimed on this repository since the store
// was opened. Applied output: measured before the unlink.
uint64 explode_evicted_bytes = 11;
// **Is `cold_bytes` a measurement?** (additive, 2026-09-28.) False from the
// znippy engine, which keeps ONE archive per repository and has no cold
// tier to count: `cold_bytes` / `cold_object_count` are then 0 by
// construction, NOT a reading, and a reader must show N/A rather than
// "0 B". False from an older server too, which sent the same bare 0.
bool cold_measured = 12;
}
message StorageInfoResponse {
// "fs" | "redb" | "tiered(redb,znippy)" | "mem" | "iceberg"
string object_backend = 1;
// "gix-ref-file" | "redb" | "mem"
string ref_backend = 2;
uint64 total_hot_bytes = 3;
uint64 total_cold_bytes = 4;
repeated RepoStorage repos = 5;
// Server-wide exploded-cache use and the sum of the per-repository budgets.
uint64 total_explode_bytes = 6;
uint64 total_explode_budget_bytes = 7;
// **Bytes held by soft-deleted repositories that have not been purged**
// NOT inside `total_hot_bytes`: a tombstoned repository has no
// catalog row, so it is in none of the `repos` rows above. It is charged to
// the account all the same, which is exactly why it is reported.
uint64 total_tombstoned_bytes = 8;
repeated TombstonedRepo tombstoned = 9;
// **The storage reality of the machine** — the installation, the volumes,
// and where each kind of data actually goes. The appliance has no shell, so
// this is the ONLY way anyone ever sees any of it.
//
// Additive (2026-09-13). A client that ignores it loses nothing.
StorageReality reality = 10;
// **Is `total_cold_bytes` a measurement?** (additive, 2026-09-28.) See
// `RepoStorage.cold_measured`: false = there is no cold tier to count on
// this engine, and the 0 is not a reading.
bool cold_measured = 11;
// **The engine, in parts** (additive, 2026-10-02, owner): `object_backend` /
// `ref_backend` above are one prose line ("znippy-plugin-git (Arrow-IPC
// archive; objects + refs)") that a console can only print whole. These are
// the same facts apart, so the console shows the NAME, the DESCRIPTION as its
// tooltip, and links the name to the engine's SOURCE repository. Unset from a
// gunnar older than this field.
StorageEngine engine = 12;
}
// One storage engine, as `Admin.StorageInfo` reports it.
message StorageEngine {
// The engine's crate: `znippy-plugin-git`, `storage-git-gix`.
string name = 1;
// One line: what it stores and how (`Arrow-IPC archive; objects + refs`).
string description = 2;
// Where its source lives (`https://git.gunnar.rs/nordisk/znippy`). ONE
// constant per forge in gunnar-server's `storage.rs`, so a move of the
// nordisk repositories is one edit.
string repo_url = 3;
}
// One `key=value` from the install record, exactly as the installer wrote it.
// Unparsed on purpose: a key this build does not know is still shown, because
// the admin reading the page may know what it means when this binary does not.
message InstallRecordField {
string key = 1;
string value = 2;
}
// One row of the placement table, **as gunnar is actually configured** — not
// as it is documented.
message PlacementRow {
// "metadata mirror (roster, principals, enrolled keys)"
string what = 1;
// The absolute path it goes to on this machine.
string path = 2;
// Is this one of the copies that must be on another failure domain?
bool requires_off_stripe = 3;
// For a row that requires it: "off-stripe" | "same-device" | "absent".
// Empty for a row that does not require it.
string off_stripe = 4;
// **Does gunnar write here today?** A directory that exists is not a
// feature, so a row that is not wired says so.
bool wired = 5;
// When `wired` is false, why not.
string note = 6;
}
// What one configured filesystem has, read at the moment of the call.
// Only the paths gunnar was TOLD to use are measured; nothing here walks a
// mount table looking for others.
message FilesystemUse {
// "data root" | "configured directory"
string what = 1;
string path = 2;
// False when the `statvfs` failed — and then the two numbers below are
// absent rather than zero, because a number that was not read must never be
// reported as one. `error` says why.
bool measured = 3;
uint64 total_bytes = 4;
uint64 free_bytes = 5;
string error = 6;
}
// **Three layers, kept distinct because they age differently.**
//
// 1. What the installer DECIDED — from the install record. Static,
// historical, and the only place the reasoning survives.
// 2. What is TRUE NOW — the configured filesystems, read at call time.
// 3. What gunnar is CONFIGURED to do — the placement table as built.
//
// Two rules matter more than completeness, and both are asserted in tests:
//
// * off-stripe is reported in three honest states — `off-stripe`,
// `same-device`, `absent` — and `same-device` is NEVER a green tick. An
// admin is often looking at this page precisely because something broke.
// * **never report a number you did not read.** If the record is missing,
// `record_present` is false and `record_absent_reason` says so; the
// installer's decisions are not inferred from the current mount table,
// because inferring is how "why is this disk unused?" gets answered wrongly.
message StorageReality {
// ── 1. what the installer decided ──────────────────────────────────────
bool record_present = 1;
// Where it was read from, when present: `/boot/korp/layout.conf` on the ESP.
string record_path = 2;
// Set iff !record_present, and it names where gunnar looked. A config that
// does not verify is ABSENT, not present-and-suspect, and this sentence is
// what says which of the two happened.
string record_absent_reason = 3;
uint32 record_version = 4;
// **Three states, and the third is the one every appliance is in today.**
// "verified" an ed25519 signature verified against the enrolled
// root key;
// "INTEGRITY ONLY" the body's blake3 matches and NOTHING else is
// claimed — `auth.signature` is empty because no
// KORP_ROOT_SIGNING_KEY is enrolled on this estate, or
// no key is enrolled here to check it against.
// These are the installer's own two words, so an admin comparing the two
// consoles is not left wondering whether they mean the same thing. A
// signature that FAILS to verify is never downgraded into this field: it
// makes the whole config absent.
string record_signature = 5;
// True only for "verified". An unsigned config is used and is never
// presented as verified.
bool record_signature_verified = 6;
// Every field of the config, in key order.
repeated InstallRecordField record_fields = 7;
// `auth.note` — the installer's own sentence, in the file's own bytes,
// saying exactly what is NOT verified. Empty on a signed config.
string record_signature_note = 17;
// ── 2. what is true now ────────────────────────────────────────────────
repeated FilesystemUse filesystems = 8;
// ── 3. what gunnar is configured to do ─────────────────────────────────
string data_root = 9;
// `serialdat.mountpoint` as the config gives it, or empty when there is no
// config. Reported, never used to build a path: every path in `placement`
// comes from that directory's own `dir.<name>.path`.
string slow_root = 10;
// How gunnar learned where to write: named by the layout config, or the
// documented default under the data root because there is none. "We were
// told" and "we fell back" are different facts.
string slow_root_source = 11;
// `serialdat.dev`, or empty when nothing said.
string backing_device = 12;
// `serialdat.class`: "p3" | "device" | "fallback", or "" when nothing said.
// A class that was not read is not reported as one.
string backing_class = 13;
// The one verdict: "off-stripe" | "same-device" | "absent".
string off_stripe = 14;
// The three copies that need another failure domain, each said as a whole
// sentence — present AND its limitation, never "OK".
repeated string off_stripe_health = 15;
repeated PlacementRow placement = 16;
// ── /data as the installer laid it, and the machine at a glance ─────────
// (additive, 2026-10-04, owner). `backing_device` / `backing_class` above
// are `/serialdat`'s, and a console once drew them under "Data root" — the
// wrong disk. These are `/data`'s own, from the layout config.
//
// `data.shape`: `raid0` | `plain` | `p3`, or empty when nothing said.
string data_shape = 18;
// `data.<N>.dev`, in N order: the devices `/data` is made of. Empty when
// there is no config.
repeated string data_devices = 19;
// **The few lines a console's Machine card shows, DECIDED HERE** — so the
// console, the CLI and any other door draw the same verdict and none of
// them decides one: `/data`'s shape, the off-stripe copies, the free
// space, the disk speed.
repeated GlanceLine glance = 20;
}
// One line of the machine at a glance.
message GlanceLine {
// `data` | `serialdat` | `free` | `speed`.
string key = 1;
// The whole sentence, e.g. `/data = raid0 over /dev/vdb, /dev/vdc`.
string line = 2;
// `ok` | `warn` | `bad` | `unknown` — gunnar's verdict, drawn as is.
string state = 3;
// Why that verdict, for the "?" beside the line. May be empty.
string detail = 4;
}
// One soft-deleted repository's held bytes.
message TombstonedRepo {
RepoPath repo = 1;
uint64 bytes = 2;
int64 deleted_at_unix_ms = 3;
int64 purge_after_unix_ms = 4;
}
// ── the provenance chain (roadmap phase 2), on the wire ─────────────────────
//
// `gunnar verify` has walked this chain from the CLI since phase 2 landed:
// line → commit → commit signature → push attestation → signed tree head.
// Gunnar is only a git server: it never verifies a build record (builds run in
// dwarves through Build Thing, artifacts live in Holger). Nothing served it, so
// the only people who could see the chain were the ones with a shell on the
// box. This RPC is the same walk, verbatim — `gunnar-server`'s `verify` module
// is the ONE implementation (one implementation) and this message set is its `Chain`
// rendered for a client.
// One link's verdict. THREE answers, never two (see `verify.rs`): a commit
// with no signature is not a commit whose signature failed, and collapsing
// UNPROVEN into either neighbour would make an unsigned repository read like a
// forged one — or worse, like a clean one.
enum ChainLinkVerdict {
CHAIN_LINK_VERDICT_UNSPECIFIED = 0;
// Checked, and it holds.
CHAIN_LINK_PROVEN = 1;
// Checked, and it does not.
CHAIN_LINK_BROKEN = 2;
// Not checked, and `detail` says why. A hole, not a pass.
CHAIN_LINK_UNPROVEN = 3;
}
// One link of the chain.
message ChainLink {
// A stable identifier a client can label in its own words:
// "line" | "signature" | "push" | "log".
string id = 1;
ChainLinkVerdict verdict = 2;
// The human-readable finding, verbatim from the walk: which commit wrote the
// line, whose key signed it, which record of the log covers the push.
string detail = 3;
}
message VerifyChainRequest {
// Required.
optional RepoPath repo = 1;
// A branch, a tag, `HEAD`, or a hex oid. Empty means `HEAD`.
string rev = 2;
// `<path>:<line>`, one-based. Empty verifies the revision itself, and link 1
// is then reported UNPROVEN rather than passed.
string target = 3;
// Was `artifact_sha256`: Gunnar no longer walks from an artifact.
reserved 4;
reserved "artifact_sha256";
}
// The chain's one-word verdict. The same three-way honesty as the links:
// INCOMPLETE is not UNBROKEN, because an unchecked link is a hole.
enum ChainSummary {
CHAIN_SUMMARY_UNSPECIFIED = 0;
// Every link is proven.
CHAIN_UNBROKEN = 1;
// A link was checked and did not hold.
CHAIN_BROKEN = 2;
// Nothing is wrong and not everything is proven.
CHAIN_INCOMPLETE = 3;
}
message VerifyChainResponse {
// `<namespace>/<repo>` the walk ran in.
string repo = 1;
// What the walk started from: the rev as given.
string from = 2;
// The `<path>:<line>` asked about, if any.
string target = 3;
// In chain order: line, signature, push, log.
repeated ChainLink links = 4;
ChainSummary summary = 5;
}
// ── capacity: the question asked BEFORE the money moves ─────────────────────
//
// A tenant on 10 GB presses "increase my quota" and asks for 100 GB. Something
// upstream is about to buy 90 GB from a cloud vendor and charge for it. Only
// gunnar knows whether that 90 GB can ever reach a repository, and on the
// appliance the honest answer is frequently **no** — so this RPC exists to be
// asked first, and to answer in a shape a seller cannot misread.
//
// # It is NOT "how much free space is there"
//
// `Admin.StorageInfo` already reports what is used. A single free-bytes number
// would be answered by a caller with "then buy another disk", and on the
// appliance that is money converted into nothing: `/data` is an md set laid
// once by `korp-installer`'s `lay_set`, which runs ONLY on
// `InstallMode::Fresh`; gunnar has no runtime data-set growth anywhere; and
// gunnar is PID 1 with no shell, so nothing on the machine can `xfs_growfs` a
// newly attached disk. A disk bought for such a box is **billed and
// invisible**.
//
// So the answer carries two independent facts, and a caller must read both:
//
// `verdict` — can THIS request be served out of what exists right now.
// `growth` — can this data set ever hold more than it does now, and by
// whose hand. Reported on the yes arm as well as the no arm,
// because "yes, and this is the last capacity this box will ever
// have" is a different sale from "yes, and there is more where
// that came from".
//
// # Measured, never declared
//
// Every byte here is one `statvfs` on the store root — the SAME measurement
// `DiskHeadroomGate` refuses pushes from (`gunnar-ssh/src/headroom.rs`), so
// the capacity a seller is quoted and the floor a push is refused at cannot
// disagree. `growth` is read off `/proc/mounts` and the md sysfs by the same
// probe the console's header band prints `4×75G raid0 xfs` from.
//
// Open source, always on: NOT behind `metered`. A self-hoster wants to know
// whether their disk can grow as much as a service provider does.
message CapacityRequest {
// **The cap being contemplated, in bytes**: "could you serve a tenant
// capped at N?" This is a `caps["pack_bytes"]` value, the same meter
// `Usage.Read` reports and the push door enforces.
uint64 requested_bytes = 1;
// **Whose cap is rising.** Optional, and it changes the arithmetic: bytes
// the namespace ALREADY holds are already spent out of free space, so only
// `requested_bytes - held_bytes` needs new room. Empty asks the harder
// question — "could you serve N bytes that are not here yet" — which is the
// right one for a brand-new tenant and the wrong one for a raise.
//
// A namespace the catalog does not hold is `NOT_FOUND`, never silently
// treated as holding nothing: quoting a raise against the wrong tenant's
// zero is how a seller oversells.
string namespace = 2;
// **Every disk, not only the data root** (additive, 2026-09-28, the
// console's admin disk view). False — the default, and what every seller
// quote sends — answers exactly as before and reads nothing more. True
// also fills `CapacityResponse.disks`: every storage mount, one statvfs
// each, and every block device from sysfs (edda's `disk-snapshot`). Opt-in
// because a quote is answered on a hot path and needs none of it.
bool disks = 3;
}
// Can the request be served out of what exists right now?
enum CapacityVerdict {
CAPACITY_VERDICT_UNSPECIFIED = 0;
// Yes: `additional_bytes` fits under `servable_bytes`. Read `growth`
// anyway — see the header.
CAPACITY_AVAILABLE = 1;
// No: `shortfall_bytes` more would be needed than may be served.
CAPACITY_EXHAUSTED = 2;
// **The filesystem could not be measured, and that is a hole, not a yes.**
// `reason` carries the OS error. The disk gate refuses writes in exactly
// this case rather than guessing there is room, and a seller must treat
// this the same way: an unmeasured box has not said yes.
CAPACITY_UNMEASURED = 3;
}
// **Can this data set ever hold more than it does now, and by whose hand?**
// Independent of `verdict`, and the field that stops "buy another disk" being
// the reflex answer to `CAPACITY_EXHAUSTED`.
enum SetGrowth {
SET_GROWTH_UNSPECIFIED = 0;
// gunnar could not tell what the data root sits on — it is not a mount
// point of its own (a developer's `--data-root /tmp/x`), or `/proc/mounts`
// was unreadable. Unknown, and NOT a promise either way.
GROWTH_UNKNOWN = 1;
// **Sealed: this server cannot gain capacity while it runs, and nothing on
// the machine can give it any.** gunnar is PID 1 — there is no init beside
// it and no shell — and it has no runtime data-set growth path in any
// build. A disk attached to this machine is billed and invisible; serving
// more needs a fresh install onto a larger set, not another disk. This is
// the appliance. When the data root is an md array the reason line also
// names it as laid once by `korp-installer`'s `lay_set`
// (`InstallMode::Fresh` only); on a single-disk PID-1 box the box is just
// as sealed and the reason says only what was measured.
GROWTH_SEALED = 2;
// gunnar itself will never grow the set — there is no runtime grow path in
// any build — but this box has an init and a shell beside it, so an
// operator can extend the filesystem out of band and gunnar will measure
// the larger one on its next `statvfs`. This is a developer box and a
// container deployment.
GROWTH_OPERATOR_ONLY = 3;
// **Grows at restart** (additive, 2026-09-14, `DATA-SET-GROWTH-DESIGN.md`).
// This box's boot stack (`korp-start`, edda's `korp-installer::grow`)
// grows `/data` into enlarged member volumes at the next boot: enlarge
// EVERY member to one equal size, restart, and the next `Capacity` reads
// the larger set. Still PID 1, still no shell, still no hot-plug: a disk
// ATTACHED to this machine is as billed-and-invisible as under
// `GROWTH_SEALED`, and an enlargement of only SOME members is refused at
// boot (`grow_state.phase = "partial"`). Measured, not declared: reported
// only when the grow journal the engine keeps exists on this box.
GROWTH_AT_RESTART = 4;
// **Grows WITHOUT a restart** (additive, 2026-09-14, `DATA-SET-GROWTH-FLOW.md`
// ruling 1). This gunnar-server links the grow engine (`grow-runtime`) and
// the journal is on this box: `Admin.GrowStart` drains, unmounts, waits
// for every member to come back larger, grows, mounts and reopens while
// the control plane and SSH auth stay up. Everything GROWTH_AT_RESTART
// says still holds as the fallback: a crash mid-way is finished at the
// next boot. Measured, not declared: reported only when both facts hold.
GROWTH_AT_RUNTIME = 5;
}
// **The grow journal, as the boot stack left it** (additive, 2026-09-14).
// `/serialdat/grow-journal` is written by `korp-start` while it grows the
// set (`epoch= target_sectors= member= step= at=`, one line per fsynced
// record); gunnar READS it and never writes it. On `Admin.Capacity` and
// `Admin.Health`, so the operator console tells one story.
message GrowState {
// The journal file exists: the boot stack that exec'd this gunnar carries
// the grow engine. False on an image older than the engine, where
// `growth` stays `GROWTH_SEALED`.
bool engine_present = 1;
// `idle` (no grow has run), `in_progress` (an epoch is open — the next
// boot resumes it), `done` (the last epoch closed at `target_sectors`),
// `partial` (the last boot refused: only `partial_members` had grown).
string phase = 2;
// The last epoch's number and per-member target, in 512-byte sectors.
uint64 epoch = 3;
uint64 target_sectors = 4;
// The records of that epoch, `G1`, `G2:0`, … `G6`, in journal order.
repeated string steps = 5;
// Members the last PARTIAL verdict found larger than the set.
repeated uint32 partial_members = 6;
// One sentence, for a human.
string detail = 7;
// Where the journal was read from.
string journal_path = 8;
}
message CapacityResponse {
CapacityVerdict verdict = 1;
SetGrowth growth = 2;
// ── what was measured, one `statvfs` on `root` ──
// The store root the numbers are of.
string root = 3;
// `f_blocks * f_frsize`.
uint64 total_bytes = 4;
// `f_bavail * f_frsize` — available to an UNPRIVILEGED writer, so the
// reserved blocks a root-owned appliance must never eat are already out.
uint64 free_bytes = 5;
// The disk floor in force (`--disk-floor`, default 5 % or 2 GiB whichever
// is larger). Space that exists and may never be sold: it is what lets the
// box compact a pack and rewrite its control documents after it stops
// accepting pushes.
uint64 floor_bytes = 6;
// `free_bytes - floor_bytes`: **what may actually be handed to a tenant.**
// The number a quote must be made against; `free_bytes` is not.
uint64 servable_bytes = 7;
// ── the arithmetic of the question ──
// Echoed from the request.
uint64 requested_bytes = 8;
// What `namespace` already holds (`Usage.Read`'s `pack_bytes`), 0 when no
// namespace was named.
uint64 held_bytes = 9;
// `requested_bytes - held_bytes`: the NEW bytes this raise needs.
uint64 additional_bytes = 10;
// `additional_bytes - servable_bytes` when EXHAUSTED, else 0. What is
// missing, not what is free.
uint64 shortfall_bytes = 11;
// What the data root IS, as the console's header band prints it:
// `4×75G raid0 xfs`, `xfs`, or empty when `growth` is UNKNOWN.
string data_set = 12;
// **One sentence naming the verdict AND the growth fact**, in the words an
// operator reads. On a sealed box that is short it says so in those terms —
// that a disk will not help and a reinstall is what would — so a caller
// that renders nothing but this string still cannot mistake the answer for
// "not enough space right now".
string reason = 13;
// ── the BOX, measured ──
// Cores and RAM are product-scope: a tenant does not own cores, the box
// does, so the ceiling a seller may sell up to for either is what THIS
// machine has, not a number written in a config. Every field here is
// `optional` and **absent when it was not measured — never 0**: an older
// client that predates them decodes the same thirteen fields above, and a
// caller that reads a `0` here has not been told the box is empty, it has
// been told nothing. `machine_reason` always says which, in words.
//
// The CPUs this process may **run on**, ×1000: the affinity mask
// (`sched_getaffinity`) further limited by a cgroup v2 `cpu.max` quota
// when one is set — the same `available_parallelism` the server budgets
// its own thread pools from, so the cores a seller is quoted are the cores
// gunnar actually schedules on. `/proc/cpuinfo` would count the HOST: on
// a 4-vCPU cloud plan that file lists the host's 32-core chip. Absent when
// the kernel would not say (the OS error is in `machine_reason`).
optional uint64 cpu_millicores_total = 14;
// `/proc/meminfo` `MemTotal`, clamped to cgroup v2 `memory.max` when a
// limit is set — inside a container `/proc/meminfo` still reports the
// HOST (measured: a server in a `MemoryMax=8G` scope read
// `MemAvailable 432.9G`), and the smaller number is the one gunnar may
// use. On the appliance, PID 1 with no limit, it is `MemTotal` itself.
optional uint64 ram_bytes_total = 15;
// `/proc/meminfo` `MemAvailable` (page cache counted as free, as the
// kernel does), clamped to `memory.max - memory.current` under a cgroup
// limit. Absent, with `ram_bytes_total`, when `/proc/meminfo` could not be
// read or parsed.
optional uint64 ram_bytes_available = 16;
// **An approximation, and it says so**: `/proc/loadavg`'s 1-minute load
// ×1000 — runnable-plus-uninterruptible tasks averaged over a minute, for
// the WHOLE box, not gunnar's own CPU time. It is the cheap honest
// "how busy" — one file read, no sampling interval — and it can exceed
// `cpu_millicores_total` on an overloaded box, because load counts
// waiting tasks. Absent when `/proc/loadavg` could not be read.
optional uint64 cpu_millicores_busy = 17;
// How the box was measured, or why a field above is absent, in words:
// names the sources (`affinity mask`, `/proc/meminfo`, `/proc/loadavg`),
// any cgroup clamp that applied, and the OS error behind every absence.
// Never empty.
string machine_reason = 18;
// The grow journal (additive, 2026-09-14): absent on a box whose boot
// stack has no grow engine, else what the last boot did about the set.
GrowState grow_state = 19;
// **The RUNNING kernel has PCI hot-plug** (additive, 2026-09-15). Measured,
// never declared: `CONFIG_HOTPLUG_PCI=y` plus a controller in
// `/proc/config.gz` when the kernel exposes it, else the built-in hot-plug
// drivers' parameter directories under `/sys/module` (`acpiphp`, `pciehp`,
// `shpchp`) — present on a kernel with CONFIG_MODULES=n too. False means a
// member detached and re-attached while gunnar runs is INVISIBLE, which is
// why `growth` is `GROWTH_AT_RUNTIME` only when this is true (UpCloud
// resizes only detached volumes).
bool hotplug = 20;
// ── the DISK, measured: the start-up disk test (additive, 2026-09-21) ──
// The three numbers `crates/gunnar-server/src/disktest.rs` takes off the
// data root at every start — a sequential write (a pack arriving), a
// sequential read (a clone leaving) and 4 KiB overwrites each followed by
// `fsync` (the ref-update pattern). They are on the wire because the twin
// election (`private-gunnar-ops`, `cargo xtask elect twin`) has to compare
// two boxes' disks and had no way to: until 2026-09-21 the row reached the
// console and stopped there.
//
// **Absent, never zero, and never a fast disk.** `disk_test` says which of
// the five things happened, and a caller that reads a `0` in any speed
// field has been told nothing — not "this disk does 0 bytes a second". In
// particular `DISKTEST_MEASURED` with `disk_seq_read_bps` ABSENT is the
// `cached` case: the read leg came back over 5 GB/s, so it did not touch
// the device and the number would be a lie. The write and sync legs of
// that same run are good and are still here.
DiskTest disk_test = 21;
// Sequential write, bytes/s. Absent unless `disk_test` is
// `DISKTEST_MEASURED`.
optional uint64 disk_seq_write_bps = 22;
// Sequential read, bytes/s, off the DEVICE. Absent unless `disk_test` is
// `DISKTEST_MEASURED` **and** the result was plausible: over 5 GB/s the
// page cache answered and the field is absent.
optional uint64 disk_seq_read_bps = 23;
// 4 KiB overwrite + `fsync`, operations per second × 1000 (milli-ops, so
// the wire stays integral the way `cpu_millicores_total` does). Absent
// unless `disk_test` is `DISKTEST_MEASURED`.
optional uint64 disk_sync_milliops_per_s = 24;
// `direct` (the page cache was bypassed) or `dropped` (`O_DIRECT` was
// refused, so the cached pages were dropped first). Empty when no read
// leg ran.
string disk_read_how = 25;
// How the disk was measured, or why a field above is absent, in words —
// the same contract `machine_reason` keeps. Never empty.
string disk_reason = 26;
// **Every disk on the box** (additive, 2026-09-28). Present only when the
// request asked (`CapacityRequest.disks`); absent otherwise and from an
// older server, and an absent message is "not asked / not known", never
// "no disks".
DiskSnapshot disks = 27;
// ── what the disk test's rates are MADE of (additive, 2026-09-28) ──
// The raw run behind `disk_seq_*_bps` and `disk_sync_milliops_per_s`: the
// bytes written (and read back), each leg's own clock, and the fsync'd
// 4 KiB operations counted — so a reader can check a rate against the run
// that produced it. Present exactly when `disk_test` is
// `DISKTEST_MEASURED`, absent otherwise, never zero-for-unknown. The read
// time is carried even when the read rate is absent as `cached`: the time
// is what happened; the rate derived from it is not a disk's.
optional uint64 disk_test_bytes = 28;
// Sequential write of `disk_test_bytes`, fsync included, microseconds.
optional uint64 disk_write_us = 29;
// Sequential read-back of the same bytes, microseconds.
optional uint64 disk_read_us = 30;
// 4 KiB random overwrites, each followed by fdatasync, done in `disk_sync_us`.
optional uint64 disk_sync_ops = 31;
optional uint64 disk_sync_us = 32;
// The whole run, start to finish, microseconds. (The start-up log row
// "serving git: the start-up disk test finished after N ms" times the WAIT
// for the test, from when the server began waiting; this is the run.)
optional uint64 disk_test_us = 33;
}
// **What the box's disks hold, read the cheap way** (additive, 2026-09-28).
// edda's product-neutral `disk-snapshot`: `/proc/self/mounts` (pseudo
// filesystems left out), one `statvfs(3)` per storage mount, the labelled
// paths gunnar cares about resolved onto the mount they sit on, and every
// `/sys/block` device with its md level and members. Nothing is inferred and
// nothing that failed to read is reported as a number: a mount whose statvfs
// failed has `measured = false` and its `error`, and a whole reader that
// failed is a line in `problems`.
message DiskSnapshot {
// When the snapshot was taken, the server's clock.
int64 measured_at_unix_ms = 1;
repeated DiskMount mounts = 2;
repeated BlockDevice devices = 3;
// Labelled paths that could not be placed on any mount, with why.
repeated UnresolvedDisk unresolved = 4;
// Whole readers that failed (`/proc/self/mounts`, `/sys/block`), in words.
repeated string problems = 5;
// **What is on /serialdat** (the system disk's P3, labelled `serialdat`
// among `mounts`): the bytes of each known thing gunnar keeps there,
// measured as the blocks each directory or file holds, plus `other` — the
// mount's used bytes not accounted for by any of them. Empty when the
// serialdat root could not be placed.
repeated PathUse serialdat = 6;
}
// The bytes one path holds on disk (allocated blocks, a directory summed).
message PathUse {
// `metadata copy` | `crash record` | `audit trail` | `logs` | `grow journal`
// | `repository mirror` | `other`.
string label = 1;
string path = 2;
bool measured = 3;
uint64 bytes = 4;
// Why `measured` is false (a missing path is measured as 0 bytes, not an
// error: nothing is there).
string error = 5;
}
// One storage mount and what its filesystem holds.
message DiskMount {
// The mount source (`/dev/md0`, `/dev/vda2`, `tmpfs`…), verbatim.
string source = 1;
string mount_point = 2;
string fs_type = 3;
bool read_only = 4;
// What gunnar calls this mount: `system` (`/`), `data` (the store root —
// its explode read cache lives here too, inside each repository), `slow`
// (the off-stripe root). Empty for a mount gunnar puts nothing on.
repeated string labels = 5;
// False when the statvfs failed; the numbers below are then absent (0)
// and `error` says why.
bool measured = 6;
string error = 7;
// `f_blocks * f_frsize`.
uint64 total_bytes = 8;
// `f_bfree * f_frsize` — free to root.
uint64 free_bytes = 9;
// `f_bavail * f_frsize` — free to an unprivileged writer.
uint64 available_bytes = 10;
// `total - free`.
uint64 used_bytes = 11;
// `free - available`: the root-reserved blocks.
uint64 reserved_bytes = 12;
// The disk floor on this mount: for `data` the `--disk-floor` in force
// (the same number `floor_bytes` above is), for every other mount the same
// rule applied to that mount's size.
uint64 floor_bytes = 13;
// `available - floor`, 0 when below it.
uint64 servable_bytes = 14;
uint64 inodes_total = 15;
uint64 inodes_free = 16;
}
// One `/sys/block` device.
message BlockDevice {
// `vda`, `md0`, `nvme0n1`.
string name = 1;
uint64 size_bytes = 2;
bool rotational = 3;
bool removable = 4;
bool read_only = 5;
// Its partitions, with their sizes.
repeated BlockPartition partitions = 6;
// What sits on top of it (an md array holding this disk or a partition of it).
repeated string holders = 7;
// For an md array: its members (`vdb1`, `vdc1`…) and level (`raid0`).
repeated string members = 8;
string raid_level = 9;
}
message BlockPartition {
string name = 1;
uint64 size_bytes = 2;
}
message UnresolvedDisk {
string label = 1;
string path = 2;
string why = 3;
}
// **Did the start-up disk test run, and what came of it?** The honesty flag
// for the three `disk_*` speed fields: a box that could NOT measure must be
// distinguishable from a box that measured badly, so every absence has a
// name here and none of them is a zero.
enum DiskTest {
// This server predates the field. Not "no test" — no answer.
DISKTEST_UNSPECIFIED = 0;
// The test's gate was closed and it never ran: this is not the appliance's
// PID 1 and `GUNNAR_DISKTEST` was not set (`disktest::spawn` returns
// false), or the server has not finished it yet. No numbers, and no
// suggestion that the disk is bad.
DISKTEST_NOT_RUN = 1;
// It ran. `disk_seq_write_bps` and `disk_sync_milliops_per_s` are present;
// `disk_seq_read_bps` is present unless the read came back cached.
DISKTEST_MEASURED = 2;
// Deliberately skipped: less free space on the data root than the test
// needs, and the test must never be the thing that fills the disk
// (`disktest::should_skip`). No numbers. `disk_reason` names both figures.
DISKTEST_SKIPPED = 3;
// The run hit an I/O error. No numbers, and `disk_reason` is the error.
DISKTEST_FAILED = 4;
}
// ─────────────────────────────────────────────────────────────────────────────
// **The runtime data-set growth** (additive, 2026-09-14,
// `DATA-SET-GROWTH-FLOW.md` §0 ruling 1: the appliance never goes down).
//
// gunnar-server grows `/data` WITHOUT a restart: drain → unmount → wait for
// the members to come back larger (an outside actor detaches, resizes and
// re-attaches them — the bench under KVM today, a front-side watchdog on
// UpCloud later; T6 says why it must be outside) → the same engine as at boot
// (edda's `korp-installer::grow`, linked as a library) → mount → reopen.
// Every step is a record in the same `/serialdat/grow-journal` the boot path
// reads, so a crash at any point is finished by the next boot.
//
// Three verbs. `GrowStart` and `GrowResume` are gunnar-admin, like every
// other mutator. `GrowStatus` is on `Admin` (gated, Read) AND on `Bootstrap`
// (NO credential): while `/data` is unmounted the identity table is served
// from RAM, and the outside actor that has to decide whether to re-attach
// must be able to read the phase with nothing in hand — exactly the caller
// `Bootstrap.Status` exists for.
// ─────────────────────────────────────────────────────────────────────────────
message GrowStartRequest {
// The per-member size to grow to, in 512-byte sectors of md data; 0 means
// "whatever every member comes back larger at" (the engine's own G1 rule:
// all present, all larger, equal within 1 %).
uint64 target_sectors_per_member = 1;
// **The go-ahead** (D3): an opaque signed blob gunnar verifies the
// STRUCTURE of (`gunnar_server::grow::GoAhead`): a JSON object with
// `v: 1`, `signer` (`operator` | `monetize`), `target_sectors` (must equal
// this request's), `nonce` (unspent on this box), `issued_unix_ms`, and
// `signature` (base64). WHO may sign is the pluggable policy: the default
// accepts an unsigned `operator` go-ahead because the RPC itself is
// gunnar-admin, and a `monetize` go-ahead is verified under monetize's
// Ed25519 key on a metered build. Empty = refused by name.
bytes go_ahead = 2;
// How long the drain may wait for in-flight transfers, seconds; 0 = 600.
// Past it the growth ABORTS with nothing changed (T1: never kill a
// transfer).
uint32 drain_timeout_secs = 3;
// How long to wait for the members to come back larger, seconds; 0 =
// 3600. Past it the phase STAYS `waiting-members` (T6: the outside actor
// may still be working) and a `GrowResume` looks again.
uint32 wait_members_timeout_secs = 4;
}
message GrowStatusRequest {}
message GrowResumeRequest {
// The outside actor says "look again": the members are back / bigger.
// With `give_up`, the operator says "stop waiting, assemble what is
// there at whatever size it is, and serve": the growth ends `failed`
// (or `done`, if the members turn out to be larger after all) and /data
// comes back.
bool give_up = 1;
}
// One member of the set, as the last survey saw it.
message GrowMember {
uint32 index = 1;
// The whole disk (`vdb`) and the member partition on it (`vdb1`).
string device = 2;
string partition = 3;
// The disk's size now, and what the set uses of it per member.
uint64 disk_bytes = 4;
uint64 set_bytes = 5;
// What this disk would hold if its partition spanned it.
uint64 offered_bytes = 6;
// Larger than the set it carries?
bool larger = 7;
}
message GrowStatusResponse {
// `idle` | `draining` | `unmounted` | `waiting-members` | `growing` |
// `mounting` | `done` | `failed`. The machine-readable word.
string phase = 1;
// `failed`'s reason, by name; empty otherwise.
string why = 2;
// The journal epoch this growth writes in; 0 when idle.
uint64 epoch = 3;
// The requested per-member target (0 = whatever is larger).
uint64 target_sectors_per_member = 4;
// The members as last surveyed (empty when the set could not be read;
// `members_verdict` says why).
repeated GrowMember members = 5;
string members_verdict = 6;
// The grow journal as it stands (the same reader `Capacity` uses).
GrowState journal = 7;
// This gunnar-server links the engine (the `grow-runtime` feature). A
// build without it answers `idle` and refuses `GrowStart` by name.
bool engine_present = 8;
// `/data` is mounted and the stores are open right now.
bool serving = 9;
// T2: the control store is being served from its RAM copy.
bool identity_in_ram = 10;
// Git transfers still in flight (meaningful while `draining`).
uint64 in_flight = 11;
// When the phase was entered, and the server's clock now.
int64 since_unix_ms = 12;
int64 server_unix_ms = 13;
// One sentence for a human.
string detail = 14;
// Who the accepted go-ahead named (`operator`, `monetize`); empty when idle.
string go_ahead_signer = 15;
// The set's md uuid, hex — "the same set came back", not another one.
string set_uuid = 16;
// `/data`'s size as mounted, bytes; 0 while unmounted.
uint64 total_bytes = 17;
// The running kernel has PCI hot-plug — the same probe `Admin.Capacity`
// reports as `hotplug`. Without it an unplugged member never comes back
// while gunnar runs and `waiting-members` waits for a restart.
bool hotplug = 18;
}
// ── the issuer, read (additive, 2026-09-17) ──────────────────────────────
//
// The response carries CERTIFICATES and never a key. That is not a promise
// this message makes; it is one `gunnar_core::Issuer` makes — the type has no
// field that could hold a private key — and this message is its wire shape.
message IssuerInfoRequest {}
// One entry of the trusted-CA list: every CA a client certificate may chain
// to. The live issuer is one of them, flagged.
message TrustedCaInfo {
// An operator-chosen label. Never part of the credential.
string label = 1;
// `SHA256:<base64>` over the certificate's DER.
string fingerprint = 2;
// The CA certificate, PEM. Public by definition.
string cert_pem = 3;
int64 added_unix_ms = 4;
// This entry is the CA the server currently MINTS with.
bool is_issuer = 5;
}
message IssuerInfoResponse {
// **Read this before anything else.** A server that has never minted or
// imported an issuer answers `false` with every other field empty, and an
// empty `fingerprint` must not be read as "the answer did not arrive": the
// two are different facts and a client that conflates them would pin
// nothing and believe it had pinned something.
bool has_issuer = 1;
// Its subject common name.
string subject = 2;
// `SHA256:<base64>` — **the value a client pins.** This is the whole point
// of the verb: the answer to "does the box in front of me mint with the CA
// my tools already trust" is this string compared to the one they hold.
string fingerprint = 3;
// The certificate itself, PEM, so a client can be handed the bytes rather
// than told to go and find them.
string cert_pem = 4;
// When this server adopted it.
int64 created_unix_ms = 5;
// "created" (minted here, the key has never been anywhere else) or
// "imported" (it arrived, so somewhere else may still hold the key).
string origin = 6;
// Every trusted CA, oldest first — including the issuer's own entry.
//
// "which CAs does this server VERIFY against" is a different question from
// "which one does it MINT with", and an operator mid-rotation is asking the
// first. `gunnar issuer show` has always printed both, and a wire read that
// printed less would be a second, poorer answer to one question.
repeated TrustedCaInfo trusted = 7;
}
// ── the audit trail, read (additive, 2026-09-21, lane T11) ───────────────
//
// The response carries the chain's SHAPE and no entry. The trail records who
// was allowed and who was refused, which is exactly the material an operator
// with server-wide Read has no business reading line by line over the wire;
// what they need — and what a restore must be provable against — is whether
// it is complete and whether it is the same one.
message AuditTrailRequest {
// **Optional: the hash this chain carried AT this sequence number.**
//
// Zero means "do not look one up". Any other value asks for the `hash` of
// the entry whose `seq` is this, which is the field a restore is checked
// with: the restored box has appended since, so its TIP has moved, but the
// hash at the primary's last sequence must be the byte-identical string the
// primary reported — and because each entry's hash commits to its `prev`,
// that one comparison proves the whole prefix.
uint64 at_seq = 1;
}
message AuditTrailResponse {
// **Is there a durable trail at all?** A server whose chain would not open
// (a full disk, a chain already broken) serves without one and says so at
// `error!`; it answers `false` here with `why` set rather than an empty
// chain, because "no trail" and "an empty trail" are different facts and a
// restore proved against the second when the first is true proves nothing.
bool present = 1;
// How many entries. `0` with `present` true is a genuinely empty chain.
uint64 entries = 2;
// The tip hash — 64 hex characters, or empty for an empty chain. This
// commits to the whole history: one changed byte anywhere moves it.
string tip = 3;
// **Does the chain hold, end to end, right now?** `AuditChain::verify`:
// every entry's own hash recomputed and every `prev` link checked. False
// means the file has been edited or truncated since gunnar wrote it, and
// `why` names the entry.
bool verified = 4;
// **Is every entry of this trail also a transaction in this box's log** —
// i.e. does the twin hold it and will a restore bring it back?
//
// False on a twin and on a box being restored, which originate nothing and
// keep a per-box trail of their own. False on a PRIMARY is the D6 defect
// and the server has already said so at `error!`.
bool replicated = 5;
// The hash at `at_seq`, when one was asked for and the chain holds that
// sequence. Empty when it was not asked for; `why` says so when it was
// asked for and the chain does not reach it — which is itself the answer
// "this box's trail does not contain the primary's history".
string hash_at_seq = 6;
// Why, for an answer that has a why: the chain would not open, the chain is
// broken and where, `at_seq` is past the end. Empty when there is nothing to
// explain.
string why = 7;
// **The sequence number of the tip entry.** `seq` is dense from 0, so this
// is `entries - 1` on a whole chain — but it is reported rather than
// computed, because the case this is asked in is a chain somebody may have
// cut, and an arithmetic answer would be confident and wrong. 0 on an empty
// chain; read `entries` to tell that apart from a one-entry chain.
uint64 tip_seq = 8;
}
message IssueTwinCertificateRequest {
// The twin's principal on this primary (a member of group `twin`).
string principal = 1;
// The twin's request, from `Bootstrap.TwinCsr` on the twin.
string csr_pem = 2;
}
message IssueTwinCertificateResponse {
string cert_pem = 1;
// This primary's issuer: the CA the twin pins.
string issuer_pem = 2;
// This primary's mutual replication listener port.
uint32 replication_port = 3;
// **Where the twin dials that port**, when it is NOT the host the twin was
// joined to: the primary's WireGuard tunnel address, once its replication
// listener has moved onto `wg0` (owner, 2026-09-22). Empty = the joined host.
string replication_host = 4;
}
// ── the estate's boxes, as this gunnar knows them (additive, 2026-10-04) ────
message TopologyRequest {}
message TopologyResponse {
// This box first, then the others in a stable order (role, then name).
repeated TopologyBox boxes = 1;
// This server's own clock when it answered.
int64 server_unix_ms = 2;
// The silence bound every `state` below was judged against, in ms.
uint64 silent_after_ms = 3;
// The estate's name from the pairing feed; empty on a gunnar nobody paired.
string estate = 4;
// ── the estate record (additive, 2026-10-04, owner: "the installer has the
// full picture — at the end of the installation gunnar-server should have
// all the info") ──
// The installer feeds every box the estate as it laid it (the shared feed's
// `estate.*` keys, persisted in the pairing's joined.toml, renewed by every
// re-admission: upgrade, recharge, a twin laid again). True when this box
// holds one; then `boxes` are exactly the laid boxes, each with its live
// state merged in. False on a solo gunnar or a box installed before it.
bool recorded = 5;
// The variant the estate was laid as (`gunnar-twin-one`, …).
string variant = 6;
// The commits the estate was laid from, one `<repository> <commit>` each.
repeated string versions = 7;
}
// **One box.** Every field is evidence gunnar holds, never a guess: a box it
// has no channel to read stays `unknown`, which is a different fact from
// `silent` (asked, or expected, and not heard).
message TopologyBox {
// A short name: the pairing name (`vm2`), a twin's or a console's
// principal, a standby's name, or this box's own pairing name.
string name = 1;
// `primary` | `twin` | `front` | `console` | `monetize` | `standby`.
// `front` is the pairing feed's front peer; `console` a console gunnar saw
// call it from a host no pairing names.
string role = 2;
// Where the evidence says it is: `host` or `host:port`; empty when unknown.
string address = 3;
// `up` (it answered, or called, within the bound) · `behind` (a twin that
// answers and does not catch up) · `silent` (expected, and not heard within
// the bound — or never) · `unknown` (nothing here can ask it).
string state = 4;
// One sentence for a human: how gunnar knows, and since when.
string why = 5;
// What runs there, as far as this gunnar can tell: `gunnar`, `console`,
// `monetize`.
repeated string services = 6;
// True for the box that answered this call.
bool this_box = 7;
// Wall clock of the newest evidence; 0 = none.
int64 last_seen_unix_ms = 8;
// `server_unix_ms - last_seen_unix_ms` at answer time; 0 when never seen.
uint64 seen_ago_ms = 9;
// The box's data volume, as measured (this box: its data root; a twin: its
// own statvfs, carried on its poll). False = not measured, and then the two
// numbers are 0 and must not be read.
bool disk_measured = 10;
uint64 disk_total_bytes = 11;
uint64 disk_used_bytes = 12;
// Where the evidence came from: `self`, `pairing`, `heartbeat`, `caller`,
// `replication`, `register`.
repeated string sources = 13;
// ── what the installer laid (the estate record; empty without one) ──
// How the kit laid it: `front` | `main` | `twin`. `role` above is what the
// pairing ELECTED (the box laid as main is normally the primary).
string laid_as = 14;
string public_address = 15;
string utility_address = 16;
string hostname = 17;
// The estate's own one-line description of the box.
string title = 18;
// The provider's size name as declared (e.g. `STARTER-4xCPU-16GB`).
string provider_size = 19;
// DECLARED, not measured: the provider's vCPU and memory, the system disk.
// 0 = not declared.
uint32 declared_vcpu = 20;
uint64 declared_ram_mb = 21;
uint64 declared_system_disk_gb = 22;
// The data disks as declared, in words: `4 × 10 GB maxiops, md-raid0`.
string declared_data = 23;
// **The state in a few words, gunnar's** (additive, 2026-10-04, owner: "the
// picture needs to be a bit dynamic, if twin dies etc."): `answering`,
// `following`, `behind 120 entries`, `silent for 4 min`, `never heard`,
// `calling`, `not asked` — plus the twin's own word when it is not plain
// following (`snapshot-needed`, `diverged`, `unreachable`). Coarse on
// purpose (whole minutes once past one), so an answer only changes when the
// state does and a console swapping the picture in place swaps it then.
string headline = 24;
}