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