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