const StateOpen, StateMerged, StateClosed
Change states.
Package forge is the domain engine of an on-chain software forge: repos, roles, an append-only reference log, issues ...
The domain engine of an on-chain software forge: repos, roles, an append-only
reference log, issues, change requests and reviews. Pure gno, no chain imports,
no realm globals. The realm that wires it to gno.land is
gno.land/r/moul/forge/v0.
A repo id is <namespace>/<name>, exactly two parts, so r/moul/home can never
be one. Without a mapping there is no way to ask which repo publishes this
package path, which is the first question anyone arriving from an explorer has.
PublishPackage records the claim and indexes it forge-wide, so PackageRepo
answers from the path alone without scanning every repo. One repo publishes many
paths (moul/gno-contracts publishes 193 of them), and a path may be claimed by
exactly one repo: silently letting a second repo take it would be a capture of
another project's package in every explorer reading this index.
The claim is not verified, and cannot be. A realm cannot ask the chain who
deployed a path. What is guaranteed is that someone with write access to the
repo said it, under a namespace r/sys/users governs. Weigh it the way you
weigh a go.mod: it says where the author says the source lives.
A Release is a tag, notes, and the object the tag names. A tag cannot be
re-cut, because a consumer who pinned v1.2.0 and later received different
bytes has been lied to. Withdrawing one is YankRelease, which leaves it
visible: a version that vanished reads as "never published" rather than "do not
use this". Releases iterate in cut order, never tag order — tags are not
required to be semver here (gno.land's own packages are v0 and v1), so any
version sort would present v10 before v2 and call it newest.
It does not store code. Git objects stay in git, behind whatever mirror a repo declares (an https remote, an IPFS CID, a peer). On gno.land a realm write locks a storage deposit of 100ugnot per byte, so a 1 MB repository would cost about 100 GNOT to park on chain and more on every push. Anchoring is the only shape that survives contact with real repositories.
What it does store is the part a forge is actually trusted for, and that git alone does not authenticate:
gno.land/... path and find the repo behind it.Every ref move appends a LogEntry: ref name, old object, new object, actor,
block height, kind (create, update, force, delete, merge), an optional
note, and a Digest committing to the previous entry's digest. Publish
LogHead() anywhere off chain and the entire history of every ref becomes
falsifiable. VerifyLog() recomputes the chain; a client should run the same
computation over the values it read back, since a transparency log nobody
verifies is just a log.
Moves are compare-and-swap:
1r.SetRef(actor, height, "refs/heads/main", expectedOID, newOID, "ship it")
expectedOID is the tip the caller last saw, empty to create the ref. A stale
expectation returns ErrStaleRef instead of overwriting. That is git's
--force-with-lease, except the lease is held by consensus rather than by the
server you are pushing to. ForceSetRef skips the expectation, needs
RoleMaintainer, and is permanently recorded as KindForce: a force-push is
not forbidden here, it is made impossible to hide.
The chain has no objects, so it cannot check that a new tip descends from the old one, and this package does not pretend otherwise. Ordering, attribution and policy are on chain; ancestry is verified by a client that holds the repo. This is the same split as gittuf's reference state log, with the log moved out of the repository and into a place no maintainer can rewrite.
RoleNone < RoleReader < RoleWriter < RoleMaintainer < RoleAdmin < RoleOwner,
totally ordered so every check is one comparison. Writers move refs, maintainers
force and merge, admins manage members and policy. The last owner cannot be
demoted. Anyone can open an issue or a change request without a role: the spam
gate is that the author pays gas and locks the deposit for their own bytes.
A Review names the object id it reviewed, not the change. Push a new head and
every earlier approval stops counting, because it approved something that is no
longer what would be merged. Nothing has to remember to dismiss it, and no
setting can turn the behaviour off. Only a writer's approval counts toward
RequiredApprovals; anyone else's review is signal, not authority. A
request-changes verdict from a writer blocks the merge while it stands.
MergeChange is a compare-and-swap on the target ref plus a policy check, and
it writes a KindMerge entry naming the change it came from.
Errors, never panics: this package is pure, so a realm turns an error into an
abort (the only way to revert state in gno) and a test asserts on the value.
Every collection is an avl.Tree, so every listing is ordered and paginatable,
and no iteration walks unbounded state. The caller supplies the actor address
and the block height, which is what makes the whole engine unit-testable with no
chain at all.
1f := forge.New()
2r, _ := f.CreateRepo(alice, height, "moul/forge", "an on-chain forge", "")
3r.SetMember(alice, bob, forge.RoleMaintainer)
4r.SetRef(alice, height, "refs/heads/main", "", oid, "initial import")
5c, _ := r.OpenChange(carol, height, "title", "body", "", "refs/heads/feat", head, "refs/heads/main")
6r.ReviewChange(bob, height, c.ID, forge.VerdictApprove, "lgtm")
7r.MergeChange(bob, height, c.ID, oid, merged, "merge change 0")
Every stored string is bounded (see the Max* constants) because an unbounded
field is an unbounded deposit. Ref names are a refs/-rooted subset of
git-check-ref-format; object ids are 40 or 64 lowercase hex characters; repo ids
are <namespace>/<name>, where the name is a lowercase slug and the namespace is
either a slug (a claimed user name) or a bech32 address. The two shapes cannot
collide: an address is 40 characters and a slug caps at 39.
This package validates the shape of a namespace and nothing else. Whether a
caller may claim one is an ownership question that needs a chain, so it lives in
the realm: a name must be held in r/sys/users, an address must be the caller's
own.
One economic rule shows up in the API: deleting is privileged. On gno.land the
storage-deposit refund goes to whoever frees the bytes, not to whoever paid for
them, so an open delete path pays for vandalism. DeleteRef needs
RoleMaintainer, and issues, comments and reviews have no delete at all.
Part of moul/gno-contracts — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage.
Dependency graph:

⚠️ Disclaimer: provided as-is, without warranty; not security-audited. Full disclaimer: DISCLAIMER.
Package forge is the domain engine of an on-chain software forge: repos, roles, an append-only reference log, issues and change requests (pull requests), with no chain imports of its own.
What it does NOT do, on purpose: store blobs, trees or packfiles. Git objects stay wherever git already puts them (a mirror, an IPFS CID, a peer) and this package records what a forge is actually trusted for and what git alone does not authenticate:
The chain cannot see the object graph, so it cannot verify that a new tip descends from the old one. It does not pretend to: every ref move is a compare-and-swap against the tip the caller expected (git's --force-with-lease, moved somewhere the forge operator cannot rewrite), any move that abandons that discipline is recorded as a force, and ancestry is checked by a client that has the objects. Ordering, attribution and policy are on chain; proof is local.
All state lives in avl trees so every listing is ordered and paginatable, and every mutation takes the actor and the block height from the caller: the package is pure, deterministic and unit-testable without a chain.
Live demo: gno.land/r/moul/forge/v0.
Change states.
Review verdicts.
1const (
2 KindCreate = "create" // a ref that did not exist now points somewhere
3 KindUpdate = "update" // compare-and-swap succeeded
4 KindForce = "force" // the tip was replaced without a matching expectation
5 KindDelete = "delete" // the ref is gone (the log is not)
6 KindMerge = "merge" // an update performed by merging a change request
7)Log entry kinds.
Size caps for a release.
1const (
2 MaxRepoPartLen = 39 // per side of "<namespace>/<name>"
3 MaxRefNameLen = 255 // git's own limit for a single ref name
4 MaxTitleLen = 200 // issue / change title
5 MaxBodyLen = 8192 // issue / change body
6 MaxCommentLen = 4096
7 MaxDescLen = 512
8 MaxNoteLen = 140 // single-line note attached to a log entry
9 MaxMirrorLen = 512
10 MaxMirrors = 8
11 MaxLabels = 10
12 MaxLabelLen = 32
13)Size caps. Every string the chain stores is bounded: an unbounded field is an unbounded storage deposit, and on gno.land the deposit is paid per byte by whoever writes it (100ugnot/byte at the time of writing).
MaxPkgPathLen bounds a published package path. gno.land's own paths are far shorter; the cap exists because every stored byte is a storage deposit somebody pays.
1const (
2 RoleNone Role = iota // not a member
3 RoleReader // explicit read (all repos are public in v0)
4 RoleWriter // move non-protected refs, update own changes
5 RoleMaintainer // force-move refs, merge changes, triage issues
6 RoleAdmin // manage members and repo settings
7 RoleOwner // admin + transfer; at least one always exists
8) 1var (
2 ErrInvalidRepoID = errors.New("forge: invalid repo id")
3 ErrInvalidRefName = errors.New("forge: invalid ref name")
4 ErrInvalidOID = errors.New("forge: invalid object id")
5 ErrInvalidText = errors.New("forge: invalid text")
6 ErrInvalidRole = errors.New("forge: invalid role")
7 ErrInvalidVerdict = errors.New("forge: invalid review verdict")
8 ErrInvalidMirror = errors.New("forge: invalid mirror locator")
9 ErrTooLong = errors.New("forge: value too long")
10 ErrTooMany = errors.New("forge: too many entries")
11 ErrRepoExists = errors.New("forge: repo already exists")
12 ErrRepoNotFound = errors.New("forge: repo not found")
13 ErrRepoArchived = errors.New("forge: repo is archived")
14 ErrRefNotFound = errors.New("forge: ref not found")
15 ErrRefExists = errors.New("forge: ref already exists")
16 ErrStaleRef = errors.New("forge: stale ref (compare-and-swap failed)")
17 ErrUnauthorized = errors.New("forge: unauthorized")
18 ErrIssueNotFound = errors.New("forge: issue not found")
19 ErrIssueClosed = errors.New("forge: issue is closed")
20 ErrChangeNotFound = errors.New("forge: change not found")
21 ErrChangeNotOpen = errors.New("forge: change is not open")
22 ErrSelfApproval = errors.New("forge: self-approval is not allowed")
23 ErrNotEnoughApproval = errors.New("forge: not enough approvals")
24 ErrChangesRequested = errors.New("forge: changes requested by a reviewer")
25 ErrSameOID = errors.New("forge: ref already points at that object")
26 ErrLastOwner = errors.New("forge: cannot demote the last owner")
27 ErrInvalidPkgPath = errors.New("forge: invalid package path")
28 ErrInvalidTag = errors.New("forge: invalid release tag")
29 ErrPackageClaimed = errors.New("forge: package path is claimed by another repo")
30 ErrPackageNotFound = errors.New("forge: package not found")
31 ErrReleaseExists = errors.New("forge: release already exists")
32 ErrReleaseNotFound = errors.New("forge: release not found")
33)Stable, machine-readable error values. Callers (realms, clients, indexers) should switch on these rather than on message text: a realm turns them into panics, and the panic string is the only thing a user sees.
AddressNamespace reports whether ns is shaped like a gno bech32 address, the namespace every account owns without registering anything. The realm still checks that it is the CALLER's address; this only says which of the two ownership rules applies.
EntryDigest computes the chain digest of e given the previous entry's digest. It is exported so an off-chain verifier can recompute the chain byte for byte from the values it read back; the field order below is the wire format and must not change within a version.
SplitRepoID splits "<namespace>/<name>" into its two halves. It does not validate either half; ok is false only when the id is not two slash-separated non-empty parts.
ValidLabel reports whether s is an issue label.
ValidLine reports whether s is single-line text within max bytes. Used for titles and for log-entry notes, which are fields of the digest chain: a newline there would let one note impersonate two.
ValidMirror reports whether s looks like a fetch locator. The chain does not resolve it: it only records where the maintainers say the objects are: so the check is a shape check, not a promise that anything is reachable.
ValidOID reports whether s is a git object id: 40 (SHA-1) or 64 (SHA-256) lowercase hex characters. Case is fixed so the same object always has the same on-chain key and the same digest-chain input.
ValidPkgPath reports whether s is a plausible gno package path.
Deliberately shallow: it checks shape, not existence, because a realm cannot ask the chain whether a path is deployed and a check that only sometimes works is worse than one that never claims to.
ValidRefName reports whether s is a fully-qualified ref name this forge accepts: a "refs/"-rooted subset of git-check-ref-format(1).
Deliberately stricter than git: the name must be fully qualified, so there is never an ambiguity between "main" the branch and "main" the tag, and a client can map an on-chain name onto a local ref without a lookup table.
ValidRepoID reports whether s is "<namespace>/<name>". The name is always a lowercase slug; the namespace is either a slug (a claimed user name) or a bech32 address (the caller's own). The two shapes cannot collide: an address is 40 characters and a slug caps at MaxRepoPartLen, which is 39.
This layer validates the SHAPE only. Whether the caller may claim a given namespace is an ownership question that needs a chain, so it belongs to the realm (see the realm's README).
ValidTag reports whether s is usable as a release tag.
Not semver-enforcing: gno.land's own packages version as v0, v1 with no minor or patch, and a forge that refused those would refuse its own ecosystem. Ordering is therefore lexical over a padded form, not semantic, and LatestRelease says so.
ValidText reports whether s fits in max bytes and carries no control characters other than newline and tab. Render output is markdown served by gnoweb, so a stray control byte is a rendering bug for every reader forever.
New returns an empty forge.
ParseRole is the inverse of Role.String.
1type Change struct {
2 ID int64
3 Title string
4 Body string
5 Author address
6 SourceRepo string // forge repo id, or a mirror locator; "" means this repo
7 SourceRef string
8 HeadOID string
9 TargetRef string
10 State string
11 CreatedAt int64
12 UpdatedAt int64
13
14 MergedOID string // the object TargetRef moved to
15 MergedBy address
16 MergedAt int64
17
18 reviews *avl.Tree // address string -> *Review (latest per reviewer)
19 comments *avl.Tree // padded id -> *Comment
20 nextComment int64
21}Change is a change request (a pull request): a claim that TargetRef should be moved to include HeadOID, plus the reviews of that claim.
Reviews are bound to the object id they reviewed, not to the change. Push a new head and every earlier approval stops counting: not by a policy toggle a maintainer can switch off, but because the approval names an object that is no longer what is being merged.
CommentCount is the number of replies on the change.
IterateComments walks replies oldest-first.
IterateReviews walks reviews in reviewer-address order.
Review returns a reviewer's latest verdict, or nil.
ReviewCount is the number of reviewers who have weighed in (latest verdict per reviewer, on any head).
Stale reports whether a review no longer applies to the change's head.
Comment is one reply, on an issue or on a change request.
1type Forge struct {
2 repos *avl.Tree // "<namespace>/<name>" -> *Repo
3 // packages is the forge-wide index behind PackageRepo: a deployed package
4 // path resolves to the repo claiming it without scanning every repo. It
5 // lives here rather than only on the repo because the question an explorer
6 // asks starts from the path and does not know the repo yet.
7 packages *avl.Tree // package path -> *Package
8}Forge is the top-level registry: repo id -> repo.
1func (f *Forge) CreateRepo(actor address, height int64, id, description, defaultRef string) (*Repo, error)CreateRepo registers a repo owned by actor.
Fork registers newID as a fork of srcID and copies the parent's current refs into the child's log, so the fork records exactly what it forked from. The objects are not copied: they never were on chain: so the child inherits the parent's mirrors as its initial fetch locators.
HasRepo reports whether the id is taken.
IterateNamespace walks the repos of one namespace in id order.
IteratePackages walks every claimed path in the forge, in path order.
IterateRepos walks repos in id order, newest-last, and stops when cb returns true. offset/count page the walk; count <= 0 means "to the end".
PackageAt returns the full claim for a path, or nil.
PackageCount is how many paths the whole forge claims.
PackageRepo is the lookup an explorer makes: given a deployed path, which repo claims to publish it. Returns "" when nobody does.
1func (f *Forge) PublishPackage(actor address, height int64, repoID, path, ref, oid, tag string) (*Package, error)PublishPackage records that this repo publishes path, and indexes it forge-wide so PackageRepo can answer without scanning every repo.
Re-publishing the same path from the same repo overwrites the entry, which is what a redeploy is. Publishing a path another repo already claims is refused: silently moving it would let any repo owner capture another project's package path in an explorer.
Repo returns the repo, or nil.
Size is the number of repos.
UnpublishPackage withdraws the claim. The package stays on chain; only the repo's assertion about it goes away.
Issue is a discussion thread bound to a repo. Anyone with an address may open one: the spam gate is not a moderator, it is that the author pays gas and locks the storage deposit for every byte they write.
CommentCount is the number of replies on the issue.
IterateComments walks replies oldest-first.
1type LogEntry struct {
2 Seq int64
3 Ref string
4 OldOID string // "" when the ref did not exist
5 NewOID string // "" on delete
6 Actor address
7 Height int64
8 Kind string
9 ChangeID int64 // the merged change, 0 otherwise
10 Note string
11 Digest string // hex sha256 over the previous digest and this entry
12}LogEntry is one link of the repo's reference log. The log is append-only and hash-chained: Digest commits to every earlier entry, so publishing a single digest (in a release note, a package manifest, a tweet) pins the entire history of every ref up to that point.
1type Package struct {
2 Path string // "gno.land/r/moul/home", the deployed package path
3 RepoID string
4 Ref string // the ref this was published from, "" if unstated
5 OID string // the object id, "" if unstated
6 Tag string // the release tag, "" if not published as part of one
7 PublishedAt int64 // block height
8 PublishedBy address
9}Package is the claim that a repo publishes a deployed package path.
This is the join nothing else in the forge could express. A repo id is "<namespace>/<name>", exactly two parts, so `r/moul/home` can never be one: there was no way to ask "which repo publishes this package path", which is the first question a reader arriving from an explorer has.
The claim is deliberately one-directional and unverified. A realm cannot ask the chain who deployed a path, so the forge cannot check that the repo really is the source of the package. What it can guarantee is that only someone with write access to the repo said so, and that the namespace of the repo is governed by r/sys/users. A reader weighs the claim the way they weigh a go.mod: it says where the author says the source lives.
Ref is the current state of one reference. The history of how it got here is in the repo log, which nothing can rewrite.
1type Release struct {
2 Tag string // "v1.2.0", the key; immutable once cut
3 Name string // human title, may be empty
4 Notes string // markdown, may be empty
5 OID string // the object this release names
6 Ref string // the tag ref, "" if the release does not have one
7 Prerelease bool
8 CreatedAt int64 // block height
9 CreatedBy address
10 Yanked bool // withdrawn; kept, because a version that existed cannot un-exist
11 YankReason string
12}Release is a named point in a repo's history, with notes.
A tag ref already says "this name points at this object". A release says why anyone should care: what changed, whether it is ready, and which deployed package paths came out of it. That last part is the one an explorer wants, and it is also the only honest bridge between a git object id and an on-chain deploy, because the chain cannot verify either against the other.
1type Repo struct {
2 ID string // "<namespace>/<name>", immutable
3 Description string
4 DefaultRef string // fully-qualified, e.g. "refs/heads/main"
5 Mirrors []string
6 ParentID string // fork lineage, "" for a root repo
7 CreatedAt int64 // block height
8 Archived bool
9
10 // Merge policy.
11 RequiredApprovals int // approvals needed to merge a change
12 AllowSelfApproval bool // may the change author's own approval count
13
14 members *avl.Tree // address string -> Role
15 refs *avl.Tree // ref name -> *Ref
16 log *avl.Tree // padded seq -> *LogEntry (append-only)
17 issues *avl.Tree // padded id -> *Issue
18 changes *avl.Tree // padded id -> *Change
19
20 packages *avl.Tree // package path -> *Package (this repo's half of the index)
21 releases *avl.Tree // tag -> *Release
22 // releaseOrder is cut order, which is the only order the forge knows to be
23 // true. Tags are not required to be semver, so sorting the release tree by
24 // key would present v10 before v2 and call it newest.
25 releaseOrder *avl.Tree // padded seq -> tag
26
27 head string // digest of the last log entry ("" when the log is empty)
28 nextSeq int64
29 nextIssue int64
30 nextChange int64
31}Repo is one repository. Nothing here is the code: Mirrors says where the objects can be fetched, Refs says what the objects are supposed to be.
Can reports whether a holds at least the given role.
Change returns a change by id, or nil.
CloseChange withdraws or rejects a change. Author or maintainer.
1func (r *Repo) CommentChange(actor address, height, id int64, body string) (*Comment, error)CommentChange appends a reply to a change request.
CommentIssue appends a reply. Closed issues still take comments (closing is a triage state, not a gag); an archived repo takes none.
CountApprovals counts approvals that still apply: cast by a writer or above, against the change's current head, and (unless the repo allows it) not the author's own.
CountBlocking counts writers who requested changes on the current head.
1func (r *Repo) CutRelease(actor address, height int64, tag, name, notes, oid, ref string, prerelease bool) (*Release, error)CutRelease records a release. The tag is the key and cannot be re-cut: a consumer that pinned v1.2.0 and got different bytes later has been lied to, and the whole point of a tag is that it does not move. Withdrawing one is YankRelease, which leaves it visible.
1func (r *Repo) DeleteRef(actor address, height int64, name, expectedOID, note string) (*LogEntry, error)DeleteRef removes a ref by compare-and-swap. The ref disappears from the current state; the log keeps every object it ever pointed at.
Maintainer-only for an economic reason as much as a safety one: on gno.land the storage-deposit refund goes to whoever frees the bytes, not to whoever paid for them (`receiver := caller` in the vm keeper's deposit path, gno master 2026-09-19), so an open delete path pays for vandalism.
1func (r *Repo) ForceSetRef(actor address, height int64, name, newOID, note string) (*LogEntry, error)ForceSetRef moves a ref without an expectation. It needs RoleMaintainer and is permanently recorded as KindForce: the point is not to forbid a force-push (sometimes it is the right call) but to make one impossible to hide.
Issue returns an issue by id, or nil.
IterateChanges walks change requests newest-first.
IterateIssues walks issues newest-first.
IterateLog walks the log oldest-first.
IterateLogReverse walks the log newest-first, which is what a UI wants.
IterateMembers walks members in address order.
IteratePackages walks the paths this repo claims, in path order.
IterateRefs walks refs in name order.
IterateReleases walks releases newest-cut first.
Cut order, not tag order: tags are not required to be semver here (gno.land's own packages are v0 and v1), so there is no total order to sort by that would not be wrong for somebody. When they were cut is a fact the forge does know.
LatestRelease is the most recently cut release that is neither a prerelease nor yanked, or nil. "Most recently cut", never "highest version": see IterateReleases.
LogEntryAt returns one entry by sequence number, or nil.
LogHead is the digest of the last entry, "" for an empty log. Pin this value anywhere off chain and the whole history becomes falsifiable.
LogSize is the number of entries ever appended.
MemberCount is the number of members with an explicit role.
1func (r *Repo) MergeChange(actor address, height, id int64, expectedTargetOID, mergedOID, note string) (*LogEntry, error)MergeChange moves TargetRef to mergedOID and records the move as one more entry in the reference log, tagged with the change it came from.
expectedTargetOID is a compare-and-swap on the target ref ("" when the ref does not exist yet): a change approved against one base cannot be merged onto a base that moved underneath it. mergedOID is computed off chain by whoever performs the merge: the chain records the claim, signed, ordered and attributed, and a client with the objects verifies that the result actually contains HeadOID.
1func (r *Repo) OpenChange(actor address, height int64, title, body, sourceRepo, sourceRef, headOID, targetRef string) (*Change, error)OpenChange files a change request. Permissionless, like an issue: the proposal costs its author gas and deposit, and costs a maintainer nothing until they choose to look.
OpenChangeCount counts change requests still open.
1func (r *Repo) OpenIssue(actor address, height int64, title, body string, labels []string) (*Issue, error)OpenIssue files an issue. Permissionless by design.
OpenIssueCount counts issues still open.
PackageCount is how many paths this repo claims.
Ref returns the current state of a ref, or nil.
Counts for rendering.
Release returns one release by tag, or nil.
ReleaseCount is how many releases this repo has cut, yanked ones included.
ReviewChange records a verdict against the change's current head. Anyone may review; only a writer's approval counts toward the merge policy (see CountApprovals): an unprivileged review is signal, not authority.
RoleOf returns the member's role, RoleNone when not a member.
SetArchived freezes (or unfreezes) the repo. An archived repo takes no mutation except unarchiving: the log stays readable forever.
SetDefaultRef points the repo at another default branch.
SetDescription updates the one-line description.
SetIssueLabels replaces an issue's labels. Triage is a maintainer action.
SetIssueOpen closes or reopens an issue. The author can always close their own; maintainers can close anyone's.
SetMember grants or revokes a role. Admins manage members; only an owner may mint another owner, and the last owner cannot be demoted: a repo with no owner is a repo nobody can ever unarchive.
SetMirrors replaces the fetch locators. The first one is the canonical remote; the rest are fallbacks. The chain records them, it never fetches.
1func (r *Repo) SetPolicy(actor address, requiredApprovals int, allowSelfApproval bool) errorSetPolicy sets the merge policy: how many approvals a change needs, and whether the author's own approval counts.
1func (r *Repo) SetRef(actor address, height int64, name, expectedOID, newOID, note string) (*LogEntry, error)SetRef moves a ref by compare-and-swap: expectedOID must be the tip the caller last saw ("" to create a ref that does not exist yet). This is git's --force-with-lease, except the lease is held by consensus rather than by the server you are pushing to, so a concurrent push cannot be silently lost and a rewritten history cannot be presented as if it had always been that way.
The chain cannot check that newOID descends from expectedOID: it has no objects. That check belongs to a client holding the repo, which is exactly why every move is recorded rather than merely applied.
UpdateChangeHead repoints an open change at a new object. The author may always update their own; a writer may update anyone's (the "maintainer pushed a fixup" case).
VerifyLog recomputes the whole digest chain and reports the first entry whose digest does not follow from its predecessor. It should be impossible on a live chain: it is here because a transparency log nobody can verify is just a log, and a client should be running this against the values it read back.
YankRelease marks a release withdrawn without deleting it.
Deleting would make a pinned version silently vanish, which reads to a consumer as "this was never published" rather than "do not use this".
Review is one reviewer's verdict on one object id.
Role is a repo-scoped capability level. Roles are totally ordered: every check is "at least this role", so there is one comparison to audit.