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