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