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

syntax = "proto3";

package gunnar.v1;

import "gunnar/v1/common.proto";

// The event stream. This is THE reason UIs stop polling: a push lights
// dwarves and korp instantly instead of after the next poll interval.
//
// Backed by a `tokio::sync::broadcast` bus that receive-pack publishes to. A
// subscriber that cannot keep up is NOT buffered without bound. It is told,
// with an `EventGap`, exactly how many events it missed. Losing events loudly
// is a design decision; growing memory quietly is not an option on a server
// that may be PID 1 in a distroless image.
//
// A stream opens with a `StreamOpened` prologue and can ask to resume from a
// cursor (`WatchRequest.from_seq`). Both exist for the same reason: a console
// that reconnects must be able to tell "nothing happened" from "I missed
// everything", and after a restart it must be able to tell that the numbering
// began again. Read `WatchRequest.from_seq` for exactly how much the buffer
// can promise — which is not durability.
service Events {
  rpc Watch(WatchRequest) returns (stream Event);
}

enum EventKind {
  EVENT_KIND_UNSPECIFIED = 0;
  EVENT_KIND_REF_UPDATED = 1;
  EVENT_KIND_REF_DELETED = 2;
  EVENT_KIND_PUSH_REJECTED = 3;
  EVENT_KIND_REPO_CREATED = 4;
  EVENT_KIND_REPO_DELETED = 5;
  EVENT_KIND_MIRROR_SYNCED = 6;
  EVENT_KIND_KEY_USED = 7;
  // Attestation: the provenance record receive-pack already has the facts for.
  EVENT_KIND_PUSH_ATTESTATION = 8;
  // Not published by the server: SYNTHESISED per subscriber when that
  // subscriber has fallen behind. It never appears in another subscriber's
  // stream and is never filtered out.
  EVENT_KIND_GAP = 9;
  // Replication: an attempt to replicate a repository to a standby gunnar failed.
  EVENT_KIND_REPLICATION_FAILED = 10;
  // Pull requests. A merge ALSO publishes REF_UPDATED for the ref move — these
  // carry the review-level fact (which pull, by whom), not the ref mechanics.
  EVENT_KIND_PULL_REQUEST_OPENED = 11;
  EVENT_KIND_PULL_REQUEST_MERGED = 12;
  EVENT_KIND_PULL_REQUEST_CLOSED = 13;
  // Not published by the server: SYNTHESISED per subscription, as the FIRST
  // message of every `Events.Watch` stream. It carries the stream's epoch, so
  // a reconnecting client learns whether its cursor still means anything
  // before it is handed a single event. Never filtered out.
  EVENT_KIND_STREAM_OPENED = 14;
  // A repository's visibility moved (`Repos.SetVisibility`). Replication's
  // PublicOnly replicas subscribe: Public → Private makes them DELETE their
  // copy, Private → Public makes them fetch one (the browse replica).
  EVENT_KIND_VISIBILITY_CHANGED = 15;
  // A repository's "encrypted" flag moved (`Repos.SetEncrypted`, wiring tracer B
  // 2026-10-08): on, the server takes ciphertext only; off, plaintext again.
  EVENT_KIND_ENCRYPTED_CHANGED = 16;
}

message WatchRequest {
  // Every filter is a disjunction within itself and a conjunction across
  // fields. An empty filter list means "no restriction on this axis"; an
  // entirely empty WatchRequest watches everything on the server.
  //
  // `namespaces` is also WHO the watch is for:
  //  * a caller holding a server-wide Read: a filter; empty = every account.
  //  * any other caller, naming accounts: a BOUNDARY — each one must be an
  //    account the caller is inside (Read over its namespace) or one whose
  //    events are public (and then only its repositories the caller may
  //    read), or the watch is refused.
  //  * any other caller, naming NONE (2026-10-04): the caller's own — every
  //    event about a repository in an account it is inside, plus events in
  //    accounts whose events are public about repositories it may read;
  //    StreamOpened and EventGap; a repo-less KeyUsed only when it is the
  //    caller's own. Decided per event, so a grant revoked mid-stream stops
  //    the next delivery; the caller's own standing (a principal, not locked
  //    out) is re-checked with the rest.
  repeated string namespaces = 1;
  repeated RepoPath repos = 2;
  repeated EventKind kinds = 3;
  // Full attestations carry every new object id, so they are opt-in
  // even when EVENT_KIND_PUSH_ATTESTATION is in `kinds`.
  bool include_attestation_object_ids = 4;

  // **The resume cursor**: the `Event.seq` of the last event this subscriber
  // processed. 0, or absent, means "start at the live tail" — which is what
  // every subscription did before this field existed, and still is.
  //
  // BEST EFFORT, AND ONLY WITHIN THE BUFFER. The bus is a bounded ring in
  // memory. There is NO durable log: it replays only what it still holds, and
  // it holds nothing at all across a restart. Do not read this as durability.
  // The contract is exactly this and nothing more:
  //
  //  * every event still buffered with `seq > from_seq` is delivered, in
  //    order, before any newly published event;
  //  * if the buffer no longer holds `from_seq + 1`, an `EventGap` is
  //    delivered FIRST — before the replay, before any live event — naming how
  //    many events were lost (`dropped`), the cursor that was asked for
  //    (`last_delivered_seq`) and the sequence the replay picks up at
  //    (`resume_seq`);
  //  * a `from_seq` at or past the newest event the bus has assigned replays
  //    nothing, and that is not a gap: there is nothing between that cursor
  //    and now.
  //
  // A cursor from a DIFFERENT RUN of the server is meaningless, and the server
  // cannot detect one for you: sequences restart at 1 on every boot, so seq
  // 900 of the previous run and seq 900 of this one are the same number.
  // Compare `StreamOpened.epoch` against the epoch you last saw before you
  // trust a cursor across a reconnect.
  uint64 from_seq = 5;
}

// The prologue: the first message of EVERY `Events.Watch` stream, ahead of any
// replay, any gap and any live event.
//
// It exists because a subscriber could not previously tell "nothing happened
// while I was away" from "the server restarted and its sequence began again".
// An idle stream sends nothing for hours, so the answer cannot ride on the
// next event; it has to be the first thing on the wire.
message StreamOpened {
  // **Identifies THIS RUN of the bus.** Opaque: compare it, never parse it.
  //
  // Same epoch as last time  → the sequence space is continuous and a cursor
  //                            from that connection still means something.
  // Different epoch          → the server restarted (or this is a different
  //                            server). Every cursor is void, `seq` counts
  //                            from 1 again, and whatever happened in between
  //                            is unknowable — nothing buffered it. A UI says
  //                            the feed restarted; it does not pretend
  //                            continuity, and it does not report a count it
  //                            cannot have.
  string epoch = 1;
  // The newest sequence the bus had assigned when this subscription opened. 0
  // before anything has been published in this run.
  uint64 last_seq = 2;
  // The oldest sequence still in the buffer, i.e. the furthest back any
  // `from_seq` can be honoured without a gap. 0 when the buffer is empty.
  uint64 oldest_buffered_seq = 3;
  // How many buffered events this stream is about to replay, before it reaches
  // the live tail. 0 for a subscription that asked for no resume.
  uint64 replaying = 4;
  // Echo of `WatchRequest.from_seq`, so a client reading the stream alone can
  // see which cursor the server acted on.
  uint64 from_seq = 5;
}

// The gap marker. `dropped` is how many events this subscriber missed; the
// next event delivered after a gap is the oldest one still in the buffer, so
// `resume_seq` tells a UI what it may safely re-fetch over Refs/Repos.
message EventGap {
  uint64 dropped = 1;
  // Sequence number of the last event known delivered before the gap. On a gap
  // that answers a `WatchRequest.from_seq`, this is that cursor.
  uint64 last_delivered_seq = 2;
  // Sequence number of the first event delivered after it. 0 when there is
  // none — the buffer no longer holds anything past the cursor.
  uint64 resume_seq = 3;
  // The same epoch `StreamOpened.epoch` carries, repeated here so a gap is
  // self-describing: the numbers either side of it are only comparable within
  // one run of the server.
  string epoch = 4;
}

message RefUpdated {
  RepoPath repo = 1;
  string ref_name = 2;
  string old_oid = 3;
  string new_oid = 4;
  string principal_id = 5;
  string key_fingerprint = 6;
  // A push that is not a fast-forward. Only reaches the stream if branch
  // protection allowed it.
  bool forced = 7;
  // **Which credential moved this ref**, as `key:SHA256:…` or
  // `token:<hash prefix>`, and empty only for a caller that presented none.
  //
  // `key_fingerprint` above answers this for SSH and is EMPTY for every push
  // over smart HTTP, because HTTP authenticates with a token and there is no
  // key to name (GUN-014). Its own doc says an empty value has "one reason
  // only — the anonymous, read-only path… That path cannot push", which was
  // not true of an HTTP push. This field is the one that is always answerable.
  //
  // Added 2026-09-11. A consumer built before it reads `key_fingerprint` and
  // is unaffected; a consumer that wants to attribute an HTTP push must read
  // this one. The console does not read it yet.
  string credential = 8;
}

message RefDeleted {
  RepoPath repo = 1;
  string ref_name = 2;
  string old_oid = 3;
  string principal_id = 4;
  string key_fingerprint = 5;
  // Which credential deleted this ref. See `RefUpdated.credential` (GUN-014).
  string credential = 6;
}

// `Repos.SetVisibility` moved a repository between Private and Public. Only
// published when `from != to`: a no-op set is not an event. `principal_id` is
// the caller who flipped it.
message VisibilityChanged {
  RepoPath repo = 1;
  Visibility from = 2;
  Visibility to = 3;
  string principal_id = 4;
}

// `Repos.SetEncrypted` turned a repository's "encrypted" flag on or off (wiring
// tracer B 2026-10-08). Only published when `from != to`. `principal_id` is the
// account admin who turned it.
message EncryptedChanged {
  RepoPath repo = 1;
  bool from = 2;
  bool to = 3;
  string principal_id = 4;
}

message PushRejected {
  RepoPath repo = 1;
  string ref_name = 2;
  // The `Gate::name()` that returned Reject.
  string gate = 3;
  string reason = 4;
  string principal_id = 5;
  // With the `atomic` capability, one rejected ref rejects the whole push.
  bool whole_push_rejected = 6;
}

message RepoCreated {
  Repo repo = 1;
  string principal_id = 2;
}

message RepoDeleted {
  RepoPath repo = 1;
  string principal_id = 2;
}

message MirrorSynced {
  RepoPath repo = 1;
  string remote_url = 2;
  uint32 refs_changed = 3;
  repeated RefUpdate updates = 4;
}

// Replication to an external mirror gunnar could not be completed.
//
// This message exists because the failure mode replication actually has is
// SILENCE: a standby that stopped being written to looks exactly like a standby
// that is up to date, and it rots for a month before anybody notices. So every
// attempt that fails says so on the stream, with the attempt number and the
// delay before the next one, and `Mirror.ReplicationStatus` carries the same
// facts for anybody who was not watching.
//
// A `ReplicationFailed` never means the originating push failed. The push had
// already succeeded and had already been reported to the client when this was
// enqueued. Replication is a subscriber, and a subscriber cannot un-accept a
// push.
message ReplicationFailed {
  RepoPath repo = 1;
  // The operator's name for the standby this was going to.
  string target = 2;
  // scp-like or ssh:// form. The URL the push was attempted against.
  string remote_url = 3;
  // 1 for the first failure of this job, incrementing per retry.
  uint32 attempt = 4;
  // How long until the next attempt. 0 when there will not be one, which is
  // the case `needs_full_resync` below is about.
  uint64 retry_in_ms = 5;
  string reason = 6;
  // The standby has a hole in it that retrying this job will not close:
  // the queue bound was hit, or the event stream was lagged past recovery.
  // Only a full resync makes it a usable standby again.
  bool needs_full_resync = 7;
}

message KeyUsed {
  string principal_id = 1;
  string fingerprint = 2;
  // "git-upload-pack" | "git-receive-pack" | "auth"
  string operation = 3;
  RepoPath repo = 4;
}

// Attestation, and why it costs ZERO dependencies.
//
// At receive-pack time gunnar already knows who pushed, with which key, into
// which repo, which refs moved and which objects are new. That is a provenance
// record for free. gunnar emits it here and appends one JSON line to its own
// log. It does NOT link the Build Thing, nornir, skade, arrow or parquet.
// Anything wanting a warehouse row SUBSCRIBES and writes the row itself. The
// coupling is this message, not a dependency edge: the only shape that
// survives the appliance case, where those crates could not be linked anyway.
message PushAttestation {
  string attestation_id = 1;
  RepoPath repo = 2;
  string principal_id = 3;
  string principal_name = 4;
  string key_fingerprint = 5;
  HashKind hash_kind = 6;
  repeated RefUpdate ref_updates = 7;
  // Objects introduced by THIS push, in the order the pack carried them.
  // Omitted unless the subscriber asked for them (WatchRequest); the counts
  // below are always present.
  //
  // EMPTY IS NOT "NONE". Read `new_object_oids_unknown` before concluding
  // anything from an empty list: see that field.
  repeated string new_object_oids = 8;
  // Objects the push introduced. ALWAYS a measurement, and it does not depend
  // on the list above being populated: it is the pack's own object count, off
  // the 12-byte header of the pack that was stored.
  uint64 new_object_count = 9;
  uint64 new_object_bytes = 10;
  // sha256 of the received packfile, hex.
  string pack_hash = 11;
  int64 received_at_unix_ms = 12;
  // "gunnar/<version>"
  string server_agent = 13;
  // "ssh": git traffic has exactly one transport.
  string transport = 14;
  // **Why `new_object_oids` is empty**, or empty when it is not.
  //
  // A push of zero objects and a push whose object ids were never worked out
  // are different facts, and an empty repeated field renders them identically.
  // So the reason travels with the record and the reader never has to guess:
  //
  // * `""` — the list is the list. Either the set was enumerated (and an empty
  //   list then means the push introduced nothing) or the ids are right there.
  // * a sentence — the list is empty for the reason it gives, and
  //   `new_object_count` is still the truth. Two reasons exist today: the
  //   store did not enumerate the set, because enumeration waits for the
  //   indexer to absorb the pack and no policy gate asked for it (the shipped
  //   default: a repository with no gates); and the subscriber did not set
  //   `WatchRequest.include_attestation_object_ids`, so the server stripped a
  //   list it did have.
  //
  // gunnar does not fabricate the list from the count. A consumer that needs
  // the ids configures a policy gate over objects, or reads them back from the
  // repository once the index has absorbed the pack.
  //
  // MERGED 2026-08-30. Both halves of this repository independently discovered
  // the same defect and added the same field at the same number under two
  // names. One survives, and this note is why the other's reasoning is here:
  // enumeration costs a drain on the push path, so an attestation from a
  // repository with no gates carried `new_object_count: 0` and an empty list —
  // "an absence that renders as a value", in the one record a verifier reads.
  string new_object_oids_unknown = 15;
  // **Which credential pushed this**, as `key:SHA256:…` or
  // `token:<hash prefix>`; empty only for a caller that presented none.
  //
  // `key_fingerprint` above is empty for every push over smart HTTP — HTTP
  // authenticates with a token and there is no key to name — while its own doc
  // says an empty value means the anonymous read-only path, "and that path
  // cannot push". This is the field that is answerable on both transports
  // (GUN-014). Added 2026-09-11; a verifier built before it is unaffected.
  string credential = 16;
}

message Event {
  // Monotonic, server-wide, starting at 1. A subscriber detects loss by a
  // jump here even if it somehow missed the EventGap.
  uint64 seq = 1;
  int64 at_unix_ms = 2;
  EventKind kind = 3;

  // Explicit and named. No `google.protobuf.Any`, because a UI must be able to
  // render an event without a type registry.
  oneof payload {
    RefUpdated ref_updated = 10;
    RefDeleted ref_deleted = 11;
    PushRejected push_rejected = 12;
    RepoCreated repo_created = 13;
    RepoDeleted repo_deleted = 14;
    MirrorSynced mirror_synced = 15;
    KeyUsed key_used = 16;
    PushAttestation push_attestation = 17;
    EventGap gap = 18;
    ReplicationFailed replication_failed = 19;
    PullRequestOpened pull_request_opened = 20;
    PullRequestMerged pull_request_merged = 21;
    PullRequestClosed pull_request_closed = 22;
    StreamOpened stream_opened = 23;
    VisibilityChanged visibility_changed = 24;
    EncryptedChanged encrypted_changed = 25;
  }
}

message PullRequestOpened {
  RepoPath repo = 1;
  uint64 number = 2;
  string title = 3;
  string source_ref = 4;
  string target_ref = 5;
  string principal_id = 6;
}

message PullRequestMerged {
  RepoPath repo = 1;
  uint64 number = 2;
  string title = 3;
  string target_ref = 4;
  // Where the target ref moved to: the fast-forwarded source tip.
  string merge_oid = 5;
  string principal_id = 6;
}

message PullRequestClosed {
  RepoPath repo = 1;
  uint64 number = 2;
  string title = 3;
  string principal_id = 4;
}