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/mirror.proto

syntax = "proto3";

package gunnar.v1;

import "gunnar/v1/common.proto";

// Pull-mirror a remote into a gunnar repo, over the client that already
// works (`nornir-git`). One command to pull a repository off any other forge,
// which is what makes gunnar adoptable without a migration project.
//
// Replication (gunnar → a standby, driven off the event bus) grows THIS
// service rather than adding a new one, exactly as this comment planned. It
// LANDED 2026-08-30, with the appliance: `SetReplication`, `RemoveReplication`
// and `ReplicationStatus` below.
//
// **They are here because the appliance has no shell.** the reference appliance is an
// OS-less image running as PID 1 — no editor, no `scp`, no `systemctl`. Replication's
// configuration lives in `<data-root>/replication.json`, and until these three
// verbs existed the only way to write that file was to have a filesystem to
// write it from, which on that box is nothing at all. A feature that ships and
// cannot be turned on is not shipped.
//
// The last four are the pull direction (a remote forge INTO gunnar) and
// the first three are the push direction (gunnar OUT to a standby). One
// service, because "where does this repository's history also live" is one
// question asked from two sides.
service Mirror {
  rpc Add(AddMirrorRequest) returns (AddMirrorResponse);
  rpc Sync(SyncMirrorRequest) returns (SyncMirrorResponse);
  rpc Status(MirrorStatusRequest) returns (MirrorStatusResponse);
  rpc Remove(RemoveMirrorRequest) returns (RemoveMirrorResponse);
  // **Store the credential this mirror dials its upstream with — write-only.**
  //
  // A verb of its own rather than two more fields on `Add`, and the reason is
  // the same one the webhook secret has: proto3 has no absent `string`, so a
  // credential riding on `Add` would be ERASED by every later `Add` that meant
  // only to flip `prune`. There is no way to spell "leave the key alone" in a
  // message whose other fields are a full restatement of the record. A separate
  // verb also has the only spelling of "forget it": `kind = UNSPECIFIED`.
  //
  // The value is stored SEALED (the same `SecretStore` the host key lives in)
  // and is never returned by any RPC, never logged and never quoted in an
  // error. `MirrorConfig.credential` says only WHICH KIND is set.
  rpc SetCredential(SetMirrorCredentialRequest) returns (SetMirrorCredentialResponse);

  // **The private CA this mirror's https upstream is issued under** (GUN-MIRROR-CA,
  // 2026-11-02). One PEM text (a CA, or a chain up to one; at most 64 KiB and 10
  // certificates; every one a CA, valid now; a private key or a server's own
  // certificate is refused by name). Trusted beside the public roots for THIS
  // mirror's calls alone, never box-wide and never "accept any"; the SSRF rule
  // still decides where it may dial. An empty `ca_pem` forgets it.
  // `MirrorConfig.ca_subject` / `ca_fingerprint_sha256` show what is set.
  rpc SetCa(SetMirrorCaRequest) returns (SetMirrorCaResponse);

  // Add or replace one standby, BY NAME, and start replicating to it now.
  // Idempotent: the same name twice replaces rather than duplicates.
  rpc SetReplication(SetReplicationRequest) returns (ReplicationStatusResponse);
  // Drop one standby by name and stop replicating to it now.
  rpc RemoveReplication(RemoveReplicationRequest) returns (ReplicationStatusResponse);
  // **DEMOTE: drop EVERY standby at once — "I am no longer a primary".**
  //
  // The failover primitive. After a primary dies and its standby is promoted,
  // the recovered old box comes back UP and SERVING, but its
  // `replication.json` still lists the box that is now the new primary — so its
  // replicator resumes pushing its now-stale state and FORCE-CLOBBERS the new
  // primary's post-failover writes (MEASURED: a client's commit made against
  // the promoted standby was overwritten back to the old primary's ref). This
  // clears the whole target list in one atomic write, so the recovered box
  // stops being a primary and is safe to re-add as the NEW primary's standby.
  //
  // Idempotent, like the rest of the family: a box that already has no targets
  // is demoted to exactly the same place, so it is safe to run again. It does
  // NOT touch the box's repositories or its own control plane — a demoted box
  // still serves git and still takes writes; it has only stopped shipping a
  // second copy anywhere.
  rpc DemoteReplication(DemoteReplicationRequest) returns (ReplicationStatusResponse);
  // **SET ROLE: is this box a writable PRIMARY, or a read-only STANDBY?**
  //
  // The failover story `DemoteReplication` began needs one more fact than the
  // target roster: whether this box will TAKE A CLIENT WRITE. `demote` stops a
  // box shipping a second copy; it does NOT stop the box itself accepting a
  // `git push`. A standby that quietly accepts a direct client push has taken a
  // write the primary will never see — the split-brain from the OTHER side.
  //
  // A box in `STANDBY` refuses `git-receive-pack` from a human/deploy key with
  // "this box is a standby; push to the primary", while still accepting the
  // replication push from its primary (a `Machine` identity). A box in
  // `PRIMARY` takes writes as it always has — the default, so every existing
  // deployment is a primary and nothing changes until an operator says so.
  //
  // `PRIMARY` is the **takeover flip**: a promoted standby runs it (or is told
  // to, as the last act of the old master's abdication) and begins taking
  // writes. Persisted in `replication.json`, so it survives the reboot
  // brings a box back from. Idempotent.
  rpc SetRole(SetRoleRequest) returns (ReplicationStatusResponse);
  // **ABDICATE: the master steps down cleanly, in order.** The voluntary
  // counterpart to the crash `DemoteReplication` covers.
  //
  // A crash is abrupt: the standby is promoted and the recovered box must be
  // demoted before it clobbers. Abdication is orderly, and the order is the
  // whole point — a master that just dies is a crash, a master that hands off
  // is an abdication:
  //   1. **role → STANDBY** — stop taking client writes, so nothing lands here
  //      after the handoff begins (closes the residual race `demote` alone left
  //      open: a client pushing to the box in the window before it is demoted);
  //   2. **flush** — resync every standby and wait for the queues to drain, so
  //      the standby has everything that was pending;
  //   3. **demote** — clear the roster (reuse `DemoteReplication`'s primitive)
  //      so the abdicated box can never later clobber the new primary.
  // What it does NOT do is promote the standby: that is the standby's own
  // `SetRole(PRIMARY)`, which the operator runs as abdication's last act (see
  // the CLI's `--promote`). Naming a new primary is a decision, not a default.
  rpc AbdicateReplication(AbdicateReplicationRequest) returns (ReplicationStatusResponse);
  // What is configured, and what each standby has actually done.
  rpc ReplicationStatus(ReplicationStatusRequest) returns (ReplicationStatusResponse);
  // **BACKFILL.** Queue every repository a standby covers — or one named
  // repository — so a standby that was added AFTER the repositories were
  // written gets them.
  //
  // Adding a standby enqueues nothing: replication is a subscriber to the event
  // push bus, so a repository's whole history rides along on its NEXT push and
  // a repository nobody pushes to again never arrives. MEASURED 2026-08-31 on
  // the reference appliance: a standby configured against 22 existing repositories held
  // 0 of them, and `alice/linux-mirror` had 3818 refs on the primary and none
  // on the standby. This is the verb that closes that, and it is the only one:
  // the appliance has no shell.
  rpc ResyncReplication(ResyncReplicationRequest) returns (ResyncReplicationResponse);
  // **FLUSH — the replication watermark** (additive, 2026-09-14,
  // `DATA-SET-GROWTH-FLOW.md` G2/T11, step 0 of the runtime growth).
  //
  // "Push everything pending to every standby and report what was reached":
  // enqueues nothing unless `resync` is set, waits — bounded — for every
  // standby's queue to drain, and answers the tuple the caller needs to
  // PROVE the twin is caught up before the set is unmounted: in-flight
  // transfers, pending per standby, bus gaps, last success, needs_full_resync.
  // No freeze to standby, no demote — unlike `AbdicateReplication` it changes
  // no role and no roster.
  //
  // What it proves: every push the replicator saw is on the twin as of
  // `flushed_at_unix_ms`. What it does NOT prove (§2.1): refs outside
  // heads/tags, LFS objects, the control database, visibility, tombstones,
  // and repositories nobody pushed since the twin existed unless `resync`
  // was run once. `verdict` says both, in words.
  rpc FlushReplication(FlushReplicationRequest) returns (FlushReplicationResponse);
  // **The transaction log's head** — what a twin asks first, every poll:
  // `head_seq` (newest committed), `first_seq` (oldest retained). On a twin
  // the answer also carries what it has applied and whether it is caught up
  // (`.nornir/twin-transaction-log.md`). Server Read.
  rpc LogHead(LogHeadRequest) returns (LogHeadResponse);
  // **A range of the transaction log**, `from..=to`, streamed as chunks —
  // big blobs (a pack, an LFS object) in 1 MiB pieces, `meta` on an entry's
  // first chunk only, `last` on its last. `from` below `first_seq` is
  // FAILED_PRECONDITION naming `first_seq`: the twin needs a snapshot, not a
  // range. Server Admin — the log holds every sealed secret's bytes.
  rpc LogFetch(LogFetchRequest) returns (stream LogChunk);
  // **A consistent snapshot** of everything the log's frames build — what a
  // twin behind a retention hole installs (additive, 2026-09-15, Lane K).
  // The same `LogChunk` pieces: every item has `seq` 0, and the last is kind
  // 12 (`SnapshotAt`) whose meta names the sequence the state was taken at;
  // the twin then fetches the log from that sequence + 1. The box freezes its
  // writes for the copy. Server Admin or group `twin`, like `LogFetch`.
  rpc SnapshotFetch(SnapshotFetchRequest) returns (stream LogChunk);
  // **Retention**: drop every whole segment of the log below `from` (the open
  // segment is closed first, so everything up to the head is droppable). A
  // twin behind the new first sequence meets a hole and takes a snapshot.
  // Server Admin only — never group `twin`. Refused on a twin (additive,
  // 2026-09-15, Lane Q).
  rpc LogRetain(LogRetainRequest) returns (LogHeadResponse);
}

// `Mirror.SnapshotFetch` (additive, 2026-09-15). Streamed: an item's `blob_len`
// is `u64::MAX` on every piece but its last, which carries the item's total
// (a pack's length is known only when it has been emitted).
//
// **Resumed** (additive, 2026-10-07, restore-after-snapshot): a box being restored from a twin
// whose log starts after a snapshot of its own stages that snapshot across several capped calls
// of monetize's restore lane, and asks for the rest from where it holds it. A twin that keeps a
// base answers every piece with `LogChunk.snapshot_id`, `snapshot_item` and `offset`; an older
// twin ignores these fields and sends its base from the start without them, which the lane
// refuses by name ("upgrade the twin"). A primary producing a snapshot of its own stores
// ignores them (its snapshot is a new cut every time and cannot be resumed).
message SnapshotFetchRequest {
  // The first item wanted (0 = the first): the items before it are not sent.
  uint64 from_item = 1;
  // The byte of item `from_item`'s blob its first piece starts at.
  uint64 from_offset = 2;
  // The `snapshot_id` the asker holds part of. A twin whose base is another one now (it took a
  // new snapshot since) refuses with FailedPrecondition `[snapshot-changed]` rather than splice
  // two snapshots: the asker starts again from item 0 with no id.
  string expect_id = 3;
}

// `Mirror.LogRetain` (additive, 2026-09-15). `from` 0 = retain nothing older
// than the next append (head + 1).
message LogRetainRequest {
  uint64 from = 1;
  // **Retain past what a twin has applied** (additive, 2026-09-22, the retention
  // guard). Without it the primary REFUSES to drop a frame the lowest-reporting
  // twin has not applied yet, naming both numbers; with it the frames go and the
  // audit trail records who overrode which twin. The twin then closes the hole by
  // snapshot. For the drill that opens a hole on purpose, and for an operator
  // who has decided a twin is gone.
  bool allow_past_twin = 2;
}

// `Mirror.LogHead` (additive, 2026-09-15).
message LogHeadRequest {
  // **The twin's half of the dual heartbeat** (additive, 2026-09-15). A
  // twin fills it on every poll; the primary records it per caller and shows
  // it on `Admin.Health`. Absent from any other caller, and then ignored.
  TwinReport report = 1;
  // **The twin's freshness challenge** (additive, 2026-09-21, lane T4). A fresh
  // random value per poll; the primary signs it back inside `answer_sig`, so an
  // answer is bound to THIS poll and a recorded one cannot be replayed at a
  // twin later. Empty from any other caller, and then the answer is unsigned.
  string nonce = 2;
  // **The newest generation of this log the twin has followed, as its main
  // signed it** (additive, 2026-10-10, stinky row 18, the main's half). A twin
  // relays it on every poll once it has one. A box that answers and finds a
  // proof of a NEWER generation of its OWN log, signed by its OWN host key, is
  // the old main come back after its replacement was restored: it fences
  // itself (`txlog-fenced`, durable) and originates nothing from then on.
  // Nobody else can make such a proof — the key that signs it is the key the
  // receiving box holds — so a forged or edited one is ignored. Absent from
  // any other caller, and from a twin that has never seen a restored main.
  GenerationProof generation_proof = 3;
}

// One `Mirror.LogHead` answer of a restored main, kept by its twin: exactly the
// fields the answer's signature covers (`gunnar-log-head-v2`), and the key that
// made it. See `LogHeadRequest.generation_proof`.
message GenerationProof {
  string answer_key = 1;
  string answer_sig = 2;
  string nonce = 3;
  string log_id = 4;
  uint64 first_seq = 5;
  uint64 head_seq = 6;
  uint64 generation = 7;
}

// What a twin tells its primary on each poll.
message TwinReport {
  // The twin's own head (everything applied).
  uint64 applied_seq = 1;
  // The twin's data volume (statvfs of its data root).
  uint64 disk_total_bytes = 2;
  uint64 disk_used_bytes = 3;
  // The twin's follower state word at the previous poll.
  string state = 4;
  // **The twin's own load** (additive, 2026-10-05): `/proc/loadavg`'s 1-minute
  // load ×1000 (the same approximation `Admin.Capacity.cpu_millicores_busy`
  // states) and the cores it may run on ×1000. Absent when not measured —
  // never 0. What the twin's autoscale reads, beside the lag.
  optional uint64 cpu_millicores_busy = 5;
  optional uint64 cpu_millicores_total = 6;
}

message LogHeadResponse {
  // Newest committed sequence; 0 when the log is empty.
  uint64 head_seq = 1;
  // Oldest sequence still retained; head + 1 when empty.
  uint64 first_seq = 2;
  // Bytes the log occupies on this box.
  uint64 bytes = 3;
  // **This box is a twin** — the fields below are meaningful.
  //
  // Read off `twin.json` ON DISK since 2026-09-21 (lane T4), not off the
  // follower's in-process status. It used to be the status, which is `None`
  // until the follower's first successful poll — so a twin whose process had
  // just restarted, or whose follower could not start at all, answered
  // `twin = false` and every reader of this field treated it as a PRIMARY:
  // `control twin replay` skipped its staleness warning entirely, and
  // `twin catchup`'s not-a-primary refusal did not fire. A box with a
  // `twin.json` is a twin whatever its follower is doing.
  bool twin = 4;
  // What the twin has applied to its own stores.
  uint64 applied_seq = 5;
  // The primary's head as the twin last saw it.
  uint64 primary_head_seq = 6;
  // `applied_seq == primary_head_seq` at the last poll.
  bool caught_up = 7;
  // One word: `following`, `snapshot-needed`, `unreachable`, `diverged`,
  // `idle`. `idle` = a twin whose follower has not reported in this process
  // yet; `diverged` = the box it follows answers a head BELOW what this twin
  // has applied, so it is a different log (additive, 2026-09-21, lane T4).
  string state = 8;
  // Why, when `state` is not `following`.
  string why = 9;
  // **When the twin last got an answer out of its primary** (unix ms, 0 =
  // never in this process). Additive, 2026-09-21, lane T4.
  //
  // `caught_up` above is `applied_seq == primary_head_seq` AT THAT MOMENT, and
  // without a clock beside it there was no way to ask the question a restore
  // has to ask — "is this copy current?" — and get an answer with an AGE.
  // A twin that stopped following ten days ago answered with the same
  // `caught_up` it answered with ten days ago. The twin is a replica for the
  // worst case; a restore that cannot see how old its source is cannot refuse
  // a stale one.
  int64 last_answer_unix_ms = 10;
  // The same, as an age at the time this answer was made: how long ago the
  // primary last answered this twin, in ms. 0 when it never has.
  uint64 answer_ago_ms = 11;
  // **WHICH LOG THIS IS** — 128 random bits in hex, minted once at this box's
  // first open (additive, 2026-09-21, lane T4). Empty from a box older than
  // this change, and then nothing is claimed and nothing is refused.
  //
  // Every other field here is a NUMBER ABOUT a log; none of them is a NAME FOR
  // one. Without this a twin compared sequence numbers against an address and
  // called that following — so a re-imaged main at a familiar address that had
  // been running long enough to pass the twin's head spliced one history onto
  // another with every number agreeing, over the only surviving copy of the
  // real one. `gunnar_server::twin::LOG_ID` has the reasoning, including why a
  // main restored FROM a twin KEEPS its id and why the host key could not be
  // used for this.
  string log_id = 12;
  // **The answering box's public key**, one OpenSSH `authorized_keys` line
  // (additive, 2026-09-21, lane T4) — its SSH host key, the same one it proves
  // itself with on :2222.
  //
  // The twin pins it on its first poll and refuses every answer that is not
  // signed by it afterwards. Until this existed the twin dialled a PLAIN
  // `http://` channel (`client_signer::plain_channel`) and verified NOTHING
  // about who answered: the credential proved the TWIN to the primary and there
  // was no direction of trust the other way, so anything that could win the
  // address — DNS, a lost lease, a cable in the wrong port — was a primary as
  // far as the twin could tell.
  //
  // A main restored from this twin replays the host key by design, so its
  // answers keep verifying and the twin keeps following it. A FRESH box at the
  // same address has a key of its own and is refused. That is the same pair of
  // outcomes `log_id` gives, arrived at independently, which is what makes the
  // two checks worth having together: one says "the same history", the other
  // says "the box I agreed to follow".
  string answer_key = 13;
  // **An `sshsig` over this answer**, PEM-armoured with newlines replaced by
  // `|` (additive, 2026-09-21, lane T4). Namespace `gunnar-twin`, never the
  // `gunnar-grpc` one a credential is signed under, so neither can ever be
  // replayed as the other. The signed text is
  // `gunnar-log-head-v1\n<nonce>\n<log_id>\n<first_seq>\n<head_seq>\n`, so the
  // freshness, the identity and the numbers stand or fall together.
  //
  // Empty when the request carried no nonce, or from a box older than this
  // change. A twin that has pinned a key refuses an unsigned answer.
  string answer_sig = 14;
  // **When this twin was last LEVEL with its primary** (unix ms, the twin's
  // clock; 0 = never). Additive, 2026-09-21, lane T4, the restore guard.
  //
  // Equal to `last_answer_unix_ms` exactly when the twin was level at its last
  // word from the primary. That is the fact a restore from a twin whose
  // primary is DEAD has to see: such a twin is always `unreachable`, so its
  // state word cannot tell "stopped following last week" from "the primary
  // died 18 s ago" (MEASURED by T3: the guard refused exactly the restore a
  // twin exists for). This can. Its age is `answer_ago_ms + (last_answer_unix_ms
  // - last_level_unix_ms)`, computed on one clock, the twin's.
  int64 last_level_unix_ms = 15;
  // **The snapshot this twin keeps as its base** (additive, 2026-10-07, restore-after-snapshot):
  // the `at` of the base it installed, so everything up to `at` is in the base and its log
  // starts at `at + 1` (`first_seq`). 0 = no base (its log starts where it always did), or a
  // box older than this field. A restore from a twin whose `first_seq` is above 1 streams this
  // base first (`Mirror.SnapshotFetch`): nothing below `first_seq` is lost.
  uint64 snapshot_at = 16;
  // The base's bytes (blob bytes of every item), for the restore's room check.
  uint64 snapshot_bytes = 17;
  // **The log's GENERATION** (additive, 2026-10-10, stinky row 18). A restore
  // keeps `log_id` and the host key on purpose, so an OLD main that comes back
  // after its replacement was restored has the same name and the same key as
  // the new one. A restore therefore stamps a newer generation (unix ms of the
  // restore, strictly above the one before). A twin pins the highest generation
  // it has followed and refuses an answer with a lower one.
  //
  // 0 = never restored, or a box older than this field. When non-zero the
  // signed text is `gunnar-log-head-v2\n<nonce>\n<log_id>\n<first_seq>\n<head_seq>\n<generation>\n`;
  // at 0 it is exactly the v1 text above.
  uint64 generation = 18;
}

// `Mirror.LogFetch` (additive, 2026-09-15).
message LogFetchRequest {
  // First sequence wanted (inclusive).
  uint64 from = 1;
  // Last sequence wanted (inclusive); 0 = the head at the time of the call.
  uint64 to = 2;
  // Stop after roughly this many blob bytes, always at an entry boundary and
  // never before one whole entry; 0 = the server's ceiling (64 MiB).
  uint64 max_bytes = 3;
  // **Start the FIRST entry (`from`) at this byte of its blob** (additive, 2026-10-07,
  // restore-large-entry): a restore resuming an entry the box being filled already holds part
  // of (`Bootstrap.Status.restore_partial_bytes`). Only the blob from the offset is read and
  // sent; its last chunk carries the entry's stored crc (`LogChunk.entry_crc`), which the box
  // that put the entry together checks over all of it. Every chunk carries `LogChunk.offset`.
  // 0 = the whole entry, its crc checked by the sender as always.
  // A box older than this field ignores it and sends the entry from byte 0 with no `offset`
  // on its chunks — the receiver skips what it holds, and says so when that cannot finish.
  uint64 from_offset = 4;
}

// One piece of one entry.
message LogChunk {
  uint64 seq = 1;
  // `gunnar_core::txlog::Kind` as stored; a twin keeps kinds it does not know.
  uint32 kind = 2;
  // The entry's meta (JSON), on its first chunk only.
  bytes meta = 3;
  // The whole blob's length.
  uint64 blob_len = 4;
  // This chunk's bytes, in order.
  bytes blob = 5;
  // The entry ends with this chunk.
  bool last = 6;
  // `Bootstrap.Restore` only (additive, 2026-09-15): the head the sender streams
  // through — the twin's `LogHead.head_seq` when the replay began. The box
  // refuses to FINISH a restore whose head is below the largest value announced
  // on the stream, and stays restoring, so a stream that ended cleanly but short
  // (a fetch that broke on the sender's side) is never a finished restore.
  // 0 = not announced (an older client): only the empty-stream refusal applies.
  uint64 restore_head = 7;
  // `Bootstrap.Restore` only (additive, 2026-09-17): how many bytes of LOG the sender is
  // about to stream — the twin's `LogHead.bytes` when the replay began. The box checks its
  // OWN data set against it on the first chunk, before anything of the restore lands, and
  // refuses by name saying how much is short. This is what makes a restore onto DIFFERENT
  // hardware — the set left four members and arrives at two — refuse rather than fill the
  // volume and fail in the middle.
  // 0 = not announced (an older client, or a sender streaming a snapshot whose size it
  // cannot know in advance): no room check, because a guess is not a measurement.
  uint64 restore_bytes = 8;
  // `Bootstrap.Restore` only (additive, 2026-09-21, lane T4): **the identity of the log
  // being streamed** — the twin's `LogHead.log_id`. The box writes it at
  // `restore_finish`, so the restored primary IS the history it was built from and the
  // twin that fed it keeps following without being touched. Empty from an older client:
  // the box then mints one of its own at its next open, and its twin — which also has no
  // id from that era — pins whatever it is told. See `gunnar_server::twin::LOG_ID` for why
  // the restored box keeps the id rather than taking a new one.
  string log_id = 9;
  // **The byte of the entry's blob this chunk's `blob` starts at** (additive, 2026-10-07,
  // restore-large-entry). Set on every chunk by a box that knows it; what lets a restore resume
  // INSIDE an entry (`LogFetchRequest.from_offset`) instead of at its first byte. ABSENT from
  // an older sender: the chunk continues its entry where the previous chunk of the same stream
  // left off, the entry's first chunk at 0 — the meaning every chunk had before.
  optional uint64 offset = 10;
  // **The entry's payload crc32c (meta ‖ blob)** as the sender's log holds it, on the `last`
  // chunk only (additive, 2026-10-07, restore-large-entry): checked by the sender before the
  // last piece left when it sent the entry whole (from an offset it cannot be: the bytes below
  // it are not read), and by the box being restored before it applies the entry — so bytes put
  // together from different calls are never applied unless they ARE the entry. Absent from an
  // older sender (and on a snapshot piece): the length check alone.
  optional uint32 entry_crc = 11;
  // **A snapshot piece (`seq` 0) from a resumable base** (additive, 2026-10-07,
  // restore-after-snapshot): the item's index in the snapshot (0-based). With `offset` (the byte
  // of the item's blob this piece starts at) and `snapshot_id` it says exactly where the piece
  // goes, so a box being restored stages a snapshot across several streams and resumes it where
  // it holds it. `entry_crc` on an item's last piece is then the item's crc32c (meta ‖ blob) as
  // the twin kept it. Absent from an older twin (and on a primary's own snapshot): the stream is
  // one piece after another from the start, as before.
  optional uint64 snapshot_item = 12;
  // **Which snapshot** these pieces are of — the twin's base, named by a digest of its item
  // table. Empty: not a resumable snapshot.
  string snapshot_id = 13;
}

// One whole entry, as the seam hands it to the service (never on the wire
// as one message: see `LogChunk`).
message LogEntry {
  uint64 seq = 1;
  uint32 kind = 2;
  bytes meta = 3;
  bytes blob = 4;
}

// What a standby TAKES. `server` is every repository; the two narrower scopes
// are how a second box takes one namespace or one repository.
message ReplicationScope {
  oneof of {
    // Every repository on this server.
    bool server = 1;
    // Every repository in this namespace.
    string namespace = 2;
    // Exactly this one.
    RepoPath repo = 3;
  }
}

// One standby, as an operator declares it. Mirrors `mirror.rs`'s
// `ReplicationTarget` field for field.
message ReplicationTarget {
  // The operator's name for it — appears in every event, log line and status
  // row, so an incident names WHICH standby rather than a URL.
  string name = 1;
  ReplicationScope scope = 2;
  // Either a BASE (`ssh://git@standby:2222`) onto which `/<ns>/<repo>.git` is
  // appended, or, for a repo-scoped target, a complete URL ending in `.git`.
  // SSH only: this is dialled by the send-pack half, which has no HTTP
  // transport.
  string remote = 3;
  // The private key this server authenticates to the standby with. A server has
  // no ssh-agent and no interactive user; a deploy key on disk is what it has.
  // Empty means the client key ladder, which on a server is almost never what
  // was meant, and the server warns about it at start-up.
  string identity_path = 4;
  // Override the known_hosts file for this target. Empty = the server's own.
  string known_hosts_path = 5;
  // Refuse an unknown host key instead of trusting it on first use.
  bool strict_host_key = 6;
  // **PublicOnly (the browse replica): this target never receives a
  // Private repository**, and one that flips Public → Private is DELETED
  // there. The browse replica's scope: what is not on the box cannot leak from
  // its anonymous surface. Default false = every repository (a standby).
  bool public_only = 7;
  // **The replica's control plane, `host:port`** — REQUIRED when `public_only`
  // is set, refused by name otherwise missing: a Public → Private flip is
  // carried out as `Repos.Delete{purge}` over this address, signed with the
  // same deploy key the send-pack authenticates with (a `Machine` on the
  // replica). Empty for a plain standby, which never needs to forget.
  string control = 8;
}

// What one standby has actually DONE.
//
// **Field for field `replication_view::StandbyRow`**, which is the same struct
// the console band on the box's own screen and `Admin.Stats` draw from. One
// reader, so `gunnar control replication list` and the band can never disagree
// about whether a standby has ever synced.
message ReplicationStandby {
  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 its queue; 0 when the last
  // attempt succeeded.
  uint32 consecutive_failures = 4;
  // The last failure since the last success. Empty when there has been none.
  string last_error = 5;
  // **0 means NEVER**, and that is the value this row exists for. Not
  // `optional`: a zero here is the answer, not a gap. "Last successful
  // replication: never" is the line that would have caught every configured
  // standby receiving nothing for weeks.
  int64 last_success_unix_ms = 6;
  // A hole retrying will not close.
  bool needs_full_resync = 7;
  // Past the staleness threshold with no success — INCLUDING never.
  bool stale = 8;
  // **The repository an attempt is in flight for**, empty when none.
  //
  // Without this a standby that has been hard-powered-off reads `pending 0
  // failures 0` for the whole of the kernel's TCP connect timeout — the job is
  // off the queue and its first attempt has not failed yet. MEASURED
  // 2026-08-31: 60 s after a power-off, with a push outstanding, the row was
  // indistinguishable from healthy. `pending` now counts this job too.
  string in_flight = 9;
  // **How long the OLDEST outstanding repository has been outstanding**, ms.
  // Zero when there is no outstanding work.
  //
  // This is the clock that keeps ticking while a standby is dead. `stale` is a
  // clock that has stopped, and a stopped clock is what made 60 s of a dead
  // standby look healthy.
  int64 outstanding_for_ms = 10;
  // Outstanding work older than the retry ceiling: the standby is BEHIND. A
  // separate fact from `stale`, which is about success and not about work.
  bool behind = 11;
  // **The one word for this standby's state**: `NEVER`, `STALE`, `BEHIND`,
  // `syncing` or `idle`. Upper case is an alarm.
  //
  // Decided by the server (`replication_view::Verdict`) rather than by each
  // surface reading the booleans for itself, so the console badge, this row,
  // the appliance band and the staleness alarm cannot disagree about the same
  // standby. Empty from a server older than 2026-08-31.
  string verdict = 12;
}

message SetReplicationRequest {
  ReplicationTarget target = 1;
}

message RemoveReplicationRequest {
  string name = 1;
}

// No fields: demote clears EVERY standby, so there is nothing to name. An empty
// message rather than a reused `RemoveReplicationRequest`, so a caller cannot
// accidentally demote by leaving a name blank.
message DemoteReplicationRequest {}

// Whether a box takes client writes. Default `PRIMARY`, so a value left unset
// is the writable box that every deployment already is — a `STANDBY` is only
// ever a box an operator deliberately made read-only.
enum ReplicationRole {
  // A writable primary: `git-receive-pack` is served as it always has been.
  REPLICATION_ROLE_PRIMARY = 0;
  // A read-only standby: direct client pushes are refused; only the
  // replication push from this box's primary (a `Machine` identity) writes.
  REPLICATION_ROLE_STANDBY = 1;
  // **A browse replica**: a STANDBY that also serves
  // anonymous reads of Public repositories, and that REFUSES every
  // control-plane mutator and `SetRole PRIMARY` by name — it can never be
  // promoted, by hand or by a passive heartbeat. Fed by a `public_only`
  // target, so it physically holds no private data.
  REPLICATION_ROLE_BROWSE = 2;
}

// The one field IS the decision: which role this box takes.
message SetRoleRequest {
  ReplicationRole role = 1;
}

// No fields: abdication is a fixed sequence (freeze → flush → demote) on THIS
// box, with nothing to parametrise. The new primary is named separately, by the
// operator, because handing primacy to a box is a decision and not a default.
message AbdicateReplicationRequest {}

message ReplicationStatusRequest {
  // **Count the refs a mirror does not carry?** Off by default, and it is off
  // for a reason: computing it opens EVERY repository's reference database, and
  // on a cold appliance that is one refold per repository — the
  // O(all repos × cold-store open) that made a single `replication list` drive
  // the box unresponsive. A plain status call answers the cheap,
  // operational signal — targets, synced/failed/pending, the verdict word, and
  // per-repository copy state — and opens no store. Set this only when an
  // operator explicitly asks what lives on the primary alone (`--refs`), and
  // the server takes one fresh walk for it.
  bool include_refs = 1;
}

// Which standby to backfill, and how much of it.
message ResyncReplicationRequest {
  // The standby, BY NAME. Required, and a name that is not configured is an
  // error: "resynced" about a standby that was never there reads identically
  // to "resynced" about the one you meant, and the difference is whether a
  // second copy is being made.
  string name = 1;
  // Absent = every repository this standby's scope covers. Present = only this
  // one, which is how a single large repository is decided about separately
  // from the rest.
  optional RepoPath repo = 2;
  // **Every repository the standby covers EXCEPT these.** Ignored when `repo`
  // names one.
  //
  // The shape a real backfill needs. On the reference appliance on 2026-08-31, 24 of the
  // 25 repositories are a few hundred megabytes together and one —
  // `alice/linux-mirror` — is ~8.7 GB and 13.9 M objects, projects 182–235 GiB
  // on a standby whose `/data` is 224.9 GiB, and is the repository whose receive
  // has never once acked (`.nornir` disaster #5). Without this the operator's
  // only way to ship the safe twenty-four is a shell loop, one call each, and a
  // box with no shell is exactly what this control plane exists for.
  repeated RepoPath except = 3;
}

// What one standby's backfill queued.
message ResyncStandby {
  string standby = 1;
  // Repositories newly queued by this call.
  uint64 queued = 2;
  // Repositories that were ALREADY outstanding, so this call cost nothing for
  // them. Not a failure: the pending job already means "converge this
  // repository".
  uint64 already_pending = 3;
  // Repositories the queue bound refused. Non-zero means the backfill is
  // INCOMPLETE and `needs_full_resync` stays set.
  uint64 refused = 4;
  // Repositories in scope that this server holds as **sha1**.
  //
  // A standby push-creates **sha256**, so a sha1 repository replicates to
  // nothing until it is pre-created on the standby with `--hash sha1`. They are
  // still queued — the operator may already have done it — but they are named
  // here rather than left to fail silently on the retry path.
  repeated string sha1_repos = 5;
  // Repositories in scope, total. `queued + already_pending + refused`.
  uint64 in_scope = 6;
  // Repositories the caller named in `except` and that this standby covers.
  // Reported rather than silently dropped: an exclusion that matched nothing
  // (a typo) and one that matched are the same command with very different
  // outcomes.
  repeated string excluded = 7;
}

message ResyncReplicationResponse {
  repeated ResyncStandby standbys = 1;
  // The roster after the call, so one round trip shows the queue it created.
  ReplicationStatusResponse status = 2;
}

message ReplicationStatusResponse {
  // Every configured target, after the change this call made (if any). This is
  // what is on disk in `<data-root>/replication.json` and therefore what
  // survives a reboot.
  repeated ReplicationTarget targets = 1;
  // One row per RUNNING standby. Empty with a non-empty `targets` means the
  // server holds the configuration and is not running it, which is a state
  // worth seeing rather than hiding.
  repeated ReplicationStandby standbys = 2;
  // Server-wide counters, from `replication_view::ReplicationReport`.
  bool configured = 3;
  uint64 enqueued = 4;
  uint64 synced = 5;
  uint64 failed = 6;
  // Times the event-bus subscription fell behind and lost events. Each one cost a
  // full resync.
  uint64 bus_gaps = 7;
  // **The public half of the deploy key this server pushes with**, as one
  // OpenSSH line, when a target was set without naming an identity.
  //
  // The appliance MINTS its own key (`<data-root>/replication_key`) and the
  // private half never leaves the box — which is the only design that works on
  // an image with no shell to place a file on, and the only one where the key
  // does not end up in a caller's shell history. Authorise this line on the
  // standby and the push works; without it every attempt fails on
  // authentication, forever, which is the failure replication was rebuilt out of.
  //
  // Empty when the target named an `identity_path` of its own.
  string identity_public_key = 8;
  // **WHICH REPOSITORIES HAVE A SECOND COPY**, one row per repository per
  // standby whose scope covers it — including the ones that have NONE.
  //
  // Everything above this line is an aggregate, and on the reference appliance on
  // 2026-08-31 the aggregates read healthy while 21 of 25 repositories had no
  // second copy. An admin reading that console concludes they are safe. These
  // rows are driven from the CATALOGUE rather than from what the replicator has
  // done, because a list built from successes can only show repositories that
  // already have a copy and the question is which ones do not.
  repeated RepoReplication repos = 9;
  // **When this server's per-repository record begins.**
  //
  // The rows above are held in memory, so a restarted server answers "no
  // record" for repositories a standby really does hold. That under-claims,
  // which is the safe direction — but a console must be able to say "no record
  // since 05:12" rather than the false "never replicated". Zero when nothing is
  // running.
  int64 observed_since_unix_ms = 10;
  // **Every ref on this server, and how many of them no standby carries.**
  //
  // Replication copies `refs/heads/*` and `refs/tags/*`. MEASURED 2026-08-31 on
  // the live primary: 1292 refs, **567 outside those prefixes** and therefore
  // on one box only. The summary line prints this so the shortfall is visible
  // without reading every row.
  uint64 refs_total = 11;
  uint64 refs_unmirrored = 12;
  // **Does this box take client writes?** `PRIMARY` (the default, and every
  // pre-2026-08-31 server), `STANDBY` (deliberately read-only) or `BROWSE`
  // (read-only, anonymous reads served, never promotable). It is the
  // fact a failover turns on and the one the roster above cannot express: a box
  // can have an empty roster and still be a writable primary, or an empty
  // roster and be a frozen standby, and only this field tells them apart.
  ReplicationRole role = 13;
}

// One repository's second copy, on one standby. **The answer to "does
// alice/edda exist on standby-fi-hel1?"**
message RepoReplication {
  RepoPath repo = 1;
  // The standby, by the operator's name for it.
  string standby = 2;
  // **0 means this server has no record of a successful replication**, and the
  // row exists precisely so that answer can be given. Read it against
  // `observed_since_unix_ms` above: 0 means "not since then", not "not ever".
  int64 last_success_unix_ms = 3;
  // **Mirrored** refs the standby held at that success: `refs/heads/*` and
  // `refs/tags/*`, which is exactly what replication copies. Note for a console
  // comparing it against `git ls-remote`: that prints `HEAD` as well, so its
  // line count is one higher on any repository with a default branch.
  uint64 refs_at_last_success = 4;
  // Work is outstanding for it right now: queued, or an attempt in flight.
  bool outstanding = 5;
  // The last failure since the last success. Empty = none.
  string last_error = 6;
  // **Retrying cannot help**: the two ends cannot hold the same repository, or
  // this server cannot read its own. A human has to act, and then
  // `Mirror.ResyncReplication`.
  bool stopped = 7;
  // This server holds it as **sha1**, and a standby PUSH-CREATES sha256. Such a
  // repository replicates to nothing until it is pre-created on the standby
  // with `--hash sha1`.
  bool sha1 = 8;
  // **This repository's refs on the PRIMARY, and how many of them a mirror
  // does not carry.** `refs_at_last_success` above counts what the standby
  // holds; these count what exists here to be held.
  //
  // Absent — not zero — when this server could not read the repository's refs.
  // A repository whose refs are all branches and tags reports `refs_unmirrored`
  // = **0**, because a counter that only appears when it is non-zero cannot be
  // told from one that is broken.
  optional uint64 refs_total = 9;
  optional uint64 refs_unmirrored = 10;
}

enum MirrorState {
  MIRROR_STATE_UNSPECIFIED = 0;
  MIRROR_STATE_IDLE = 1;
  MIRROR_STATE_SYNCING = 2;
  MIRROR_STATE_FAILED = 3;
}

// **Which kind of credential a mirror dials with**, and therefore which
// transports it may use. The VALUE is never on this wire in either direction
// except on `SetMirrorCredentialRequest`, which is the one message that carries
// it INTO the server.
enum MirrorCredentialKind {
  // No credential stored. A public remote needs none and mirrors fine.
  MIRROR_CREDENTIAL_KIND_UNSPECIFIED = 0;
  // An OpenSSH private key, for an `ssh://` remote. Refused by name against an
  // `https://` one: a key that cannot be offered is a mirror that fails on
  // authentication for ever with nothing naming the cause.
  MIRROR_CREDENTIAL_KIND_SSH_KEY = 1;
  // A token, for an `https://` remote — `Authorization: Bearer <token>`, or
  // Basic when `username` is set (which is what GitHub, Codeberg and GitLab
  // personal access tokens want). Refused by name against an `ssh://` remote.
  MIRROR_CREDENTIAL_KIND_TOKEN = 2;
}

message MirrorConfig {
  RepoPath repo = 1;
  // scp-like, `ssh://` or `https://`. Both transports run the SAME
  // `gunnar-wire` `fetch_pack` client; what differs is only how the byte pair
  // is obtained — a russh channel, or a buffered request/response adapter over
  // `ureq`. (This comment used to say "SSH only"; that was true until
  // `Mirror.Sync` became real.)
  string remote_url = 2;
  // Delete local refs the remote no longer has.
  bool prune = 3;
  MirrorState state = 4;
  int64 added_at_unix_ms = 5;
  optional int64 last_sync_at_unix_ms = 6;
  string last_error = 7;
  uint64 sync_count = 8;
  // **Which kind of credential is stored — never the credential.**
  //
  // Modelled on `WebhookEndpoint.has_secret`, one step wider because there are
  // two kinds here and picking the wrong one is a refusal an operator has to be
  // able to see coming. `UNSPECIFIED` means none is stored.
  MirrorCredentialKind credential = 9;
  // The username a `TOKEN` credential is paired with for Basic auth. NOT a
  // secret — a token's username is `x-access-token`, `oauth2` or the account
  // name, and every forge documents it. Empty means `Bearer`.
  string credential_username = 10;
  // **The sync job this repository has on the queue, or its last one.**
  //
  // Unset when the server is holding none: none was ever asked for, or the last
  // one finished long enough ago to have been dropped. Absence and "it did
  // nothing" are different answers and this is which — the same rule
  // `GcStatusResponse.job` states.
  MirrorJob job = 11;
  // **The repository is made by the first sync that reaches the upstream**
  // (2026-10-18). `Mirror.Add { create_repo }` no longer creates it up front:
  // the object format (sha1 or sha256) and the default branch are the
  // upstream's, and only the upstream can say which — a private upstream says
  // nothing until its credential is set. Until a sync gets that far there is
  // a registration and no repository, so a failed first import leaves no empty
  // repository behind.
  bool repo_pending = 12;
  // **The upstream uses Git LFS, and its LFS files were not copied.** Set
  // when the last successful sync found `filter=lfs` in the default branch's
  // `.gitattributes`: the mirror holds the pointer files, not the content.
  bool lfs_not_copied = 13;
  // The private CA set for this mirror (`Mirror.SetCa`): its subject and the
  // SHA-256 of its DER (hex). Empty when none is set.
  string ca_subject = 14;
  string ca_fingerprint_sha256 = 15;
}

// Where a sync job is. `gunnar_server::job_queue::JobState`'s four words, the
// same four `GcState` and `PurgeState` render — one runner, so they cannot
// drift apart about what DONE means.
enum MirrorJobState {
  MIRROR_JOB_STATE_UNSPECIFIED = 0;
  // Waiting its turn. `queue_position` says how many are ahead of it.
  MIRROR_JOB_STATE_QUEUED = 1;
  // The one mirror sync this server is running.
  MIRROR_JOB_STATE_RUNNING = 2;
  // Finished; `objects`, `bytes` and `updates` are the answer.
  MIRROR_JOB_STATE_DONE = 3;
  // Finished badly. `error` says how, and NO reference was moved — the whole
  // plan is applied in one compare-and-swap or none of it is.
  MIRROR_JOB_STATE_FAILED = 4;
}

// One queued, running or completed sync. **`GcJob`'s shape**, plus the two
// things a gc has no use for: a progress pair, and a list of what moved.
message MirrorJob {
  // Stable for the life of the job, so a console can tell "still the sync I
  // asked for" from "somebody else's, started after mine". `mirror-<n>`.
  string job_id = 1;
  RepoPath path = 2;
  MirrorJobState state = 3;
  // How many jobs are ahead of this one. 0 while running, done or failed — a
  // queue position is a fact only about waiting.
  uint32 queue_position = 4;
  // **How far along, in OBJECTS.** `progress_total` is the count the packfile
  // header declared, published the instant the header arrives; `progress_done`
  // is what the ingest reported it indexed.
  //
  // Read `has_progress` first: a job that has not reached the pack header yet
  // has no denominator, and `0/0` would be a progress bar that lies about
  // being at zero per cent rather than admitting it does not know.
  bool has_progress = 5;
  uint64 progress_done = 6;
  uint64 progress_total = 7;
  // Objects the fetch brought in. 0 on a mirror that was already up to date,
  // which is the ordinary answer for a poll and not a failure.
  uint64 objects = 8;
  // Packfile bytes taken off the wire, header and trailer included. This one
  // moves WHILE the fetch runs — it is the byte counter `fetch_pack` streams.
  uint64 bytes = 9;
  // References this sync moved, and how they moved. Empty until DONE.
  repeated RefUpdate updates = 10;
  // Only set when `state` is FAILED. Never contains the credential.
  string error = 11;
  int64 enqueued_at_unix_ms = 12;
  // 0 until the job leaves QUEUED.
  int64 started_at_unix_ms = 13;
  // 0 until the job leaves RUNNING.
  int64 finished_at_unix_ms = 14;
  // **Why a FAILED sync was refused by the namespace's own plan**
  // (2026-10-04), beside `error`'s sentence, so a console reads a field
  // instead of parsing gunnar's prose. UNSPECIFIED for every other failure,
  // and for a job that did not fail.
  MirrorRefusal refused_reason = 15;
}

enum MirrorRefusal {
  MIRROR_REFUSAL_UNSPECIFIED = 0;
  // The namespace's plan refuses writes at all — suspended, lapsed. Nothing
  // was fetched.
  MIRROR_REFUSAL_PLAN = 1;
  // The namespace is at its pack room, or this fetch would have passed it and
  // was cut at the ceiling. Nothing was stored.
  MIRROR_REFUSAL_PACK_CAP = 2;
}

message AddMirrorRequest {
  RepoPath repo = 1;
  string remote_url = 2;
  bool prune = 3;
  // Create the target repo if it does not exist, taking its hash kind from
  // the remote's advertised object-format (never assume sha1).
  //
  // **It never takes over a repository that exists** (2026-10-04): with
  // `create_repo` set, an existing repository that is not already a mirror is
  // refused (FAILED_PRECONDITION, naming it) — an import whose name collided
  // used to turn somebody's repository into a mirror, and a pruning sync could
  // then delete its branches. Re-adding an existing MIRROR is unchanged.
  bool create_repo = 4;
  // Take over an existing, non-mirror repository on purpose: it becomes a
  // mirror of `remote_url`, and its next sync moves its refs to the
  // upstream's. Only meaningful with `create_repo`; without it the repository
  // must exist anyway and the caller named it.
  bool adopt = 5;
}
message AddMirrorResponse {
  MirrorConfig mirror = 1;
}

message SyncMirrorRequest {
  RepoPath repo = 1;
}
message SyncMirrorResponse {
  MirrorConfig mirror = 1;
  // **0 and empty on the enqueue, and that is not "up to date".**
  //
  // `Sync` used to be `UNIMPLEMENTED` precisely so it could not answer "0 refs
  // changed" to a caller who would read that as success. It answers with a JOB
  // now, and these two fields are filled in only once that job is DONE — which
  // it is not when `Sync` returns, because a fetch is minutes of network and a
  // unary RPC that waited for one would time out. `job.state` is the field to
  // read; poll `Mirror.Status` until it is DONE and take `job.updates`.
  uint32 refs_changed = 2;
  repeated RefUpdate updates = 3;
  // **The job now responsible for this repository**: the one this call created,
  // or the one that was already queued or running for it. Two clicks are one
  // job — dedup is on the repository, and the job id says which one you got.
  MirrorJob job = 4;
}

message SetMirrorCredentialRequest {
  RepoPath repo = 1;
  // `UNSPECIFIED` FORGETS the stored credential. There is no separate delete
  // verb: "no credential" is a value this field can hold, and a public remote
  // that needs none is the ordinary case rather than a special one.
  MirrorCredentialKind kind = 2;
  // The OpenSSH private key (`-----BEGIN OPENSSH PRIVATE KEY-----`, and the rest of it) or the
  // token. **Stored sealed and never returned, logged or quoted in an error.**
  // Must be empty when `kind` is `UNSPECIFIED`, and non-empty otherwise — both
  // refused by name, because "I set a credential" and "I cleared one" must
  // never be the same request read two ways.
  string secret = 3;
  // The Basic-auth username to pair a `TOKEN` with. Empty = `Bearer`. Ignored,
  // and refused by name, for an SSH key.
  string username = 4;
}
message SetMirrorCaRequest {
  RepoPath repo = 1;
  // PEM; empty forgets the CA.
  string ca_pem = 2;
}

message SetMirrorCaResponse {
  MirrorConfig mirror = 1;
}

message SetMirrorCredentialResponse {
  // The registration after the change, so one round trip shows `credential`.
  MirrorConfig mirror = 1;
}

message MirrorStatusRequest {
  // Both empty reports every mirror on the server.
  string namespace = 1;
  optional RepoPath repo = 2;
}
message MirrorStatusResponse {
  repeated MirrorConfig mirrors = 1;
}

message RemoveMirrorRequest {
  RepoPath repo = 1;
}
message RemoveMirrorResponse {}

// `Mirror.FlushReplication` (additive, 2026-09-14).
message FlushReplicationRequest {
  // How long to wait for every standby's queue to drain, seconds; 0 = the
  // abdicate ceiling (`GUNNAR_ABDICATE_FLUSH_SECS`, default 120).
  uint32 timeout_secs = 1;
  // Enqueue EVERY repository for every standby first (a full backfill, the
  // abdicate way) rather than flushing only what is pending. The only way
  // a repository nobody pushed since the twin existed reaches it.
  bool resync = 2;
}

message FlushReplicationResponse {
  // Every standby: pending 0, no full-resync hole, a success on or after the
  // flush began — and no git transfer in flight on this box.
  bool caught_up = 1;
  // Git transfers in flight on this box when the wait ended.
  uint64 in_flight = 2;
  // Repositories still outstanding, summed over the standbys.
  uint64 pending = 3;
  // Pushes the bus dropped before the replicator saw them, since boot.
  uint64 bus_gaps = 4;
  // Any standby has a hole retrying will not close.
  bool needs_full_resync = 5;
  // The OLDEST last-success over the standbys, ms; 0 = a standby never synced.
  int64 last_success_unix_ms = 6;
  // When the flush began and how long it waited.
  int64 flushed_at_unix_ms = 7;
  uint64 waited_ms = 8;
  // Per standby, as `Mirror.ReplicationStatus` reports them.
  repeated ReplicationStandby standbys = 9;
  // Repositories in the catalog (what a complete twin must hold).
  uint64 catalog_repos = 10;
  // One sentence: what this proves and what it does not.
  string verdict = 11;
}