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

syntax = "proto3";

package gunnar.v1;

// The REVIEW on a pull request, as signed append-only events (roadmap phase 1).
//
// # This service does not own the pull request
//
// `PullRequests` (pulls.proto) does: the number, the refs, the state and the
// merge oid are one record under the control store's compare-and-swap, and that
// record is their one authority. This service owns what people SAID — comments
// and approvals — because that is the half whose value depends on a signature.
//
// So `pull` here IS `PullRequest.number`. There is no numbering in this service
// and no opened/merged/closed event: those are transitions of the record, and a
// second copy of them here would be a twin that could disagree with it.
//
// # The server does not sign, and that is the whole design
//
// Every write carries a signature the CALLER made with their own key. The
// server verifies it against the identity key table and appends it; it never signs on
// anybody's behalf. A forge whose server signs approvals is a forge where the
// server can manufacture one, and then an approval is a database row rather
// than evidence.
//
// # It is a gate, not a display
//
// `PullRequests.Merge` refuses unless a verified approval names the commit it
// is about to fast-forward to. An approval of an earlier tip is not an approval
// of this one, so a force-push after review does not inherit the review — the
// merge is refused and says which commit was actually approved.
//
// # Nothing here is stored folded
//
// `GetReview` folds the events every time it is asked. Two readers of the same
// events always agree, and a state nobody can point at an event for cannot
// exist.
service Forge {
  // Append one already-signed event, after verifying it.
  rpc Append(AppendEventRequest) returns (AppendEventResponse);
  // One pull request's review, folded, with every approval re-checked.
  rpc GetReview(GetReviewRequest) returns (GetReviewResponse);
  // The review on every pull request in a repository that has one.
  rpc ListReviews(ListReviewsRequest) returns (ListReviewsResponse);
  // One issue, folded from its signed events, with the whole thread.
  rpc GetIssue(GetIssueRequest) returns (GetIssueResponse);
  // Every issue in a repository, folded.
  rpc ListIssues(ListIssuesRequest) returns (ListIssuesResponse);
}

message Commented {
  string body = 1;
  // **The inline anchor.** When `path` is empty this is a comment on the pull
  // request as a whole (the original behaviour, byte-for-byte); when it is set
  // the comment is about ONE line of ONE file version, and all three fields are
  // inside the signature.
  //
  // The anchor names the BLOB the line lived in, not the pull request's tip, so
  // the comment follows the CODE: a rebase that does not touch this file leaves
  // its blob oid unchanged and the comment stays on its line, while a rebase
  // that rewrites the file gives it a new blob oid and the comment correctly no
  // longer attaches — the line it was about is gone. A comment keyed on the
  // pull number instead would ride along onto a rewritten file it was never
  // made on.
  string path = 2;
  // The one-based line number within `blob_oid`, the way an editor counts.
  uint32 line = 3;
  // The full hex oid of the blob the commented line lived in.
  string blob_oid = 4;
}
// An approval of a SPECIFIC commit. The commit is inside the signature, so a
// force-push after the review leaves an approval that visibly names the commit
// that was reviewed and not the one that is there now.
message Approved {
  string commit = 1;
}
message Rejected {
  string commit = 1;
  string reason = 2;
}

// **An issue opened**, as a signed event (roadmap phase 1).
//
// Unlike a review, an issue has no `PullRequests`-style record that owns its
// state: the issue IS the fold of its own signed events. So there is an
// `IssueOpened` here where there is deliberately no `Opened` for a review — a
// review's opening is a transition of the pull request record, an issue's is
// the only thing that says the issue exists at all.
//
// Making the opening a SIGNED event is the whole point, and it is the thing
// GitHub, GitLab and Gitea do not have: who filed this issue is the actor's own
// `sshsig`, checkable later by anybody without trusting the server that stored
// it, exactly as an approval is.
//
// The issue's number is NOT here: it is assigned by the fold — the Nth
// `IssueOpened` in a repository is issue N — so two readers of the same log
// always agree on it, the same way they agree on a review. The author is
// [`ForgeEvent.actor`]; a second copy in this message would be a field that can
// disagree with the signature's own attribution.
message IssueOpened {
  string title = 1;
  string body = 2;
}
// A comment on an issue. The issue number rides in [`ForgeEvent.pull`] — the
// subject field — so a comment is bound by the signature to the thread it is
// on and cannot be moved to another.
message IssueCommented {
  string body = 1;
}
// **An issue closed.** A later `IssueOpened` cannot re-open it (that would be a
// new issue with a new number); a fold that saw a close after an open reports
// the issue shut, and the close stays readable — the reason included — which is
// the fact an auditor wants.
message IssueClosed {
  string reason = 1;
}

// One thing that happened, exactly as it was signed.
message ForgeEvent {
  string repo = 1;
  // The SUBJECT this is about. For a review event it is `PullRequest.number`,
  // assigned by `PullRequests.Create`. For an issue COMMENT or CLOSE it is the
  // issue number the fold assigned. For an `IssueOpened` it is 0 and unused:
  // the number does not exist until the fold assigns it. Inside the signature,
  // so it is known before signing (a comment cannot be re-pointed at another
  // thread).
  uint64 pull = 2;
  // The identity-table principal the event is attributed to.
  string actor = 3;
  int64 at_unix_ms = 4;
  oneof kind {
    Commented commented = 6;
    Approved approved = 7;
    Rejected rejected = 8;
    IssueOpened issue_opened = 9;
    IssueCommented issue_commented = 10;
    IssueClosed issue_closed = 11;
  }
}

// A `ForgeEvent` with the signature that makes it evidence.
message SignedForgeEvent {
  ForgeEvent event = 1;
  // `SHA256:…` of the actor's key — or, on an `attested` event, of gunnar's
  // own forge-attestation key.
  string key_fingerprint = 2;
  // The `sshsig`, PEM-armoured.
  string signature = 3;
  // **Gunnar's attestation** (GHUNT-19): set only by gunnar, on an event a
  // person made from an identity-core session that stepped up. Such a session
  // holds no key of the person's, so `Forge.Append` takes the event UNSIGNED
  // (empty `key_fingerprint` and `signature`) from that session, and gunnar
  // signs it with its own key, the actor named, this attestation inside the
  // signature. A client never sends one: `Append` refuses it.
  ForgeAttestation attested = 4;
}

// What gunnar attests about an event it signed for a person's session.
message ForgeAttestation {
  // When the session stepped up (a fresh, user-verified touch of the person's
  // own passkey, within the identity core's window), Unix milliseconds.
  int64 session_stepped_up_at_unix_ms = 1;
}

// One approval, and what re-checking it against the identity key table said RIGHT
// NOW — not what it said when it was written.
message Approval {
  string actor = 1;
  string commit = 2;
  // True only when a registered key verified the signature. An approval whose
  // key is not in the table is not an approval that failed, and it is not one
  // that passed either; `verdict` says which.
  bool verified = 3;
  // "verified", "UNKNOWN KEY", "NO EVENT", or the problem, in words.
  string verdict = 4;
}

// One pull request's review, folded from its events.
//
// Deliberately NOT the pull request: the title, the refs, whether it is open
// and what it merged at are `PullRequest`'s, and duplicating them here would be
// two answers to one question.
message ReviewState {
  string repo = 1;
  uint64 pull = 2;
  repeated Approval approvals = 3;
  uint64 comments = 4;
}

message AppendEventRequest {
  SignedForgeEvent event = 1;
}
message AppendEventResponse {
  // Where in the forge log it landed. The index a transparency inclusion proof
  // is taken at.
  uint64 index = 1;
}

message GetReviewRequest {
  string repo = 1;
  uint64 pull = 2;
}
message GetReviewResponse {
  ReviewState state = 1;
  // Every event on this pull request, in the order they were appended.
  repeated SignedForgeEvent events = 2;
}

message ListReviewsRequest {
  string repo = 1;
}
message ListReviewsResponse {
  // Only pull requests somebody has actually commented on or approved. A pull
  // request with no review has no row here, which is not the same as it not
  // existing — ask `PullRequests.List` for that.
  repeated ReviewState reviews = 1;
}

// One issue, folded from its signed events.
//
// Unlike `ReviewState`, this DOES carry the title, the body and whether it is
// open, because — also unlike a review — there is no other record that owns
// them. The issue is nothing but the fold of its events, so the fold is the
// only authority there is.
message IssueState {
  string repo = 1;
  // Assigned by the fold: the Nth `IssueOpened` in the repository is issue N.
  uint64 number = 2;
  string title = 3;
  string body = 4;
  // The identity-table principal that opened it — the opening event's actor.
  string author = 5;
  bool open = 6;
  uint64 comments = 7;
  int64 opened_at_unix_ms = 8;
}

message GetIssueRequest {
  string repo = 1;
  uint64 number = 2;
}
message GetIssueResponse {
  IssueState state = 1;
  // Every event on this issue, in the order they were appended: the opening,
  // every comment, and the close if there is one.
  repeated SignedForgeEvent events = 2;
}

message ListIssuesRequest {
  string repo = 1;
}
message ListIssuesResponse {
  repeated IssueState issues = 1;
}