docs/superpowers/specs/2026-08-09-repo-governance-design.md
Ref: Size: 16.7 KiB History
# Repository Governance — Design
Date: 2026-08-09
Status: Draft — supersedes the hook/`refs/for`/in-band-policy design of the same
date. Depends on `2026-08-09-patch-revision-refs-design.md`.
## Purpose
Govern the server the way gitolite does: a git repository holds the keys and the
access rules, an admin pushes to it to reconfigure the server, and a push that
would install a broken config is rejected rather than applied. Nothing is edited
on the host.
The addition is patches. Under a branch workflow, contribution requires write
access to `refs/heads/*`, so restricting a contributor means granting the
namespace and then carving `main` back out with deny rules — fragile, and easy
to get subtly wrong. With patches carried entirely under `refs/collab/*`
(revision-refs design), contribution is a single prefix grant and canonical
branches are never mentioned in a contributor's rule at all.
## Deployment context
Single-tenant. One operator across several machines, plus agent identities. The
server runs as a pod; out-of-band recovery is `kubectl exec`. The web UI is
internet-facing.
## Threat model
The adversary is **a compromised agent credential**. It can open an SSH
connection and push. It must not be able to move any repository's canonical
state, widen its own access, reach a repository it was not granted, or create
repositories outside its own namespace.
An adversary with write access to the storage directory is out of scope: the SSH
host private key already lives there (`main.rs:38`,
`<repos_dir>/.server/host_key`), so that access permits impersonating the server
outright. If the host key moves out of `repos_dir`, revisit this — the
internet-facing HTTP server runs in the same process and uid (`main.rs:87-102`).
Agents running as local worktrees on the operator's machine are a **different**
threat and are out of scope here. Such an agent holds the operator's key and
shell; no server-side control can distinguish it. Its containment is that its
deliverable is a patch the operator reads before merging, plus per-agent signing
keys for after-the-fact attribution. That is a review boundary, and it is the
real one.
## Design test
> If a feature only works when the server is `git-collab-server`, it is not a
> git-collab feature.
Access control passes this test. A contributor's workflow is byte-identical on
every host — `git-collab sync` — and on this forge a push they should not be
making is refused. That is what branch protection is, and it is not part of the
collaboration protocol.
Server-side *protocol* work fails the test. `refs/for/<branch>` conversion,
in-band policy interpretation, and anything that rewrites what a client pushed
are out of scope permanently, not deferred.
The distinction is what the client must know. A doorman is invisible to it; a
translator is not.
## The settings repository
`settings.git` is an ordinary repository on the server, governed by the same
rules as any other, and reconfigures the server when pushed to.
```
conf/access.conf repo blocks and access rules
keydir/<name>.pub one public key per file
keydir/<dir>/<name>.pub same, for a second device
```
### Identity
The identity is the **basename** of the key file, with directories ignored, as in
gitolite. `keydir/laptop/alex.pub` and `keydir/desktop/alex.pub` are both `alex`.
This is the whole answer to one operator with several machines: adding a device
is adding a file, and revoking one is `git rm`. No syntax, and the diff is
legible in review.
On the wire the principal is still the key fingerprint (`ssh_key_principal`,
`session.rs:171`). The server maps fingerprint to name at authentication time, so
names appear in config and fingerprints appear on the connection. An unmapped
fingerprint is not a principal and is refused.
### Rules
```
@admins = alex
@agents = claude-a claude-b
repo settings
RW+ = @admins
repo tools
RW+ = @admins
RW refs/collab/ = @agents
R = @all
repo agents/[a-z].*
C = @agents
RW+ = CREATOR
```
Line-oriented rather than TOML, deliberately. Evaluation is ordered first-match,
and order is load-bearing; TOML tables do not express an ordered rule list
without an array-of-tables that reads far worse than the above.
Semantics follow gitolite: a refex defaults to `refs/.*`, a bare name is
implicitly `refs/heads/`, refexes anchor at the start but not the end, `-` denies,
`@all` matches everything, and rules are gathered in order and filtered to the
accessing principal before the first match decides.
The permission set is `R`, `RW`, `RW+`, `C`. `C` is create, needed only for the
wild-repo pattern below. Deletion is folded into `RW+` rather than given its own
letter; a single-tenant forge does not need the distinction, and it can be split
out later without changing any existing rule's meaning.
The `@agents` line above is the point of the whole design: an agent's grant is
one prefix, and `refs/heads/` never appears in it.
### Wild repositories
`repo agents/[a-z].*` with `C = @agents` lets an agent create its own repository
without a central allocator, and `RW+ = CREATOR` gives it full control of what it
created and nothing else. Two agents cannot collide, because the pattern places
each in its own path.
This is what removes the need for a server-level authority over repository
creation. Creation is a rule like any other.
## Enforcement
Two points, because the server learns the repository and verb at one moment and
the refs at a later one.
**At dispatch**, immediately after path resolution and before the command is
dispatched (`session.rs:506`), so every exec verb inherits it — `git-upload-pack`,
`git-receive-pack`, and each `collab-release` subcommand including ones added
later. This resolves repository-level access: `R` to fetch, any of `RW`/`RW+`/`C`
to push, `RW+` to upload or delete a release, `R` to list releases.
**At receive**, per ref, via a server-managed hook that applies the refex rules.
The hook is installed and owned by the server, is never authored by a user, and
does no protocol work — it answers "may this principal write this ref" and
nothing else. This is the gitolite `update` hook, and it is the kind of hook the
design test permits.
Two adjacent changes are prerequisites. `run_git_command` (`session.rs:708`)
spawns `git-receive-pack` with the server's inherited environment and nothing
per-request, so passing the principal is new. And the child's stderr is piped and
never read (`session.rs:711-716`), so a rejection message is currently discarded
and a chatty hook can fill the pipe buffer and wedge the child.
### Validation on push
A push to `settings.git` is validated before it takes effect: the config must
parse, every key file must be a well-formed public key, and the result must leave
at least one principal with `RW+` on `settings`. A push failing any of these is
rejected, and the previous configuration stays live.
This deletes a failure mode rather than handling it. The superseded design had a
malformed-policy path, a bounded-staleness window and two timeout values to
choose; none are needed, because a config that does not compile never becomes the
running config.
Reading remains fallible at runtime — a genuine IO error on the config blob is
classified by `git2::ErrorCode`, not by message inspection, and closes the server
to writes rather than opening it. `NotFound` is an authoritative absence, not an
IO error.
### Refusal behaviour
Dispatch-time refusals — unknown repository, unreadable repository — return an
identical `repository not found` reply and exit status, so a scoped credential
cannot map what else exists.
Per-ref refusals are necessarily distinguishable: by the time the hook runs the
advertisement has already disclosed the repository's refs. This is acceptable
because the principal has already passed the read check for that repository.
### Repository lookup key
The resolved path relative to the storage directory with one trailing `.git`
removed, `/` separators preserved, matched byte-exactly. `private/tools.git` and
`tools.git` are distinct repositories.
Wild repositories make nested paths load-bearing rather than optional. Path
resolution already accepts them (`session.rs:967`), but `discover` is
non-recursive (`repos.rs:217`) and HTTP resolves by final component
(`repos.rs:241`), so nested repositories are unreachable over HTTP today and
`tools.git` and `tools` already collide there. HTTP resolution and the URL scheme
must be reworked as part of this.
## Exposure: unlisted by default, published by rule
Revised 2026-08-11. The original text below assumed `server.toml` could simply
be deleted; it cannot, and the reason is worth stating because it is the same
reason the rule language needs a new token.
`access.conf` grants to **named principals**, and `@all` means every *enrolled*
key — every identity with a file in `keydir/`. That is a strictly different set
from "anybody at all", so no rule in the language as specified can describe an
unauthenticated HTTP request. Deleting `server.toml` would therefore delete the
anonymous axis with nothing left to express it, which is why the implementation
(`b6dcbb7b`) kept `visibility`, `[ui] anonymous` and `[http] anonymous_clone`
there and had `settings.git` supersede it only on the access axis.
The fix is to invert the default rather than to translate the old keys.
**A repository is unlisted and unreadable without authentication unless a rule
says otherwise.** The absence of configuration is then a decision rather than a
gap, and `settings.git` only ever has to express the *exception* — a positive
grant, which is what the language is already good at. This also generalises a
special case the implementation already had to hand-code: `settings.git` is
forced off the anonymous surface unless it opts in, because creating it must not
publish the key roster to an internet-facing UI.
Two axes, deliberately separate, because "not in the list" and "404 to a direct
URL" are different states and a repo reachable if you know its name is a real
configuration:
```
repo tools
RW+ = @admins
RW refs/collab/ = @agents
R = @all # every enrolled key
R = @anonymous # unauthenticated read: reachable by name
option listed = yes # and advertised in the repo list
```
`@anonymous` is a **reserved principal** and belongs in the rule table because it
is an access decision, resolved by the same first-match-wins evaluation as any
other — including `-` to deny it back. `listed` is an **option**, not a rule,
because advertising is a display concern rather than an access one; this is the
`option` line gitolite already has, so it is stolen rather than invented.
Three consequences that are easy to get wrong, and each of which an implementer
would otherwise have to guess:
**`option listed = yes` requires `R = @anonymous`, and is a config error
without it.** The tempting reading — "listed, but only to viewers who can read
it" — is meaningless here: web UI authentication is explicitly out of scope
below, so HTTP has no identity and there is no authenticated web viewer to show
it to. Listing a repo nobody may read would advertise a name that 404s. Reject
the pair at push validation rather than rendering it.
**`@anonymous` never satisfies a write grant.** `RW = @anonymous` is a config
error, rejected by push validation, not silently ignored.
**The inverted default applies only where governance is in force.** A server
with no `settings.git` keeps today's behaviour exactly — that is already an
asserted requirement (`tests/governance_test.rs`, the `ungoverned` harness) and
flipping the default globally would silently hide every repository on every
existing deployment the moment it upgraded. The default is a property of the
governed world, not of the binary.
**Release downloads follow the repo's anonymous read grant.** They are served
over HTTP from the repo's gitdir, so `R = @anonymous` governs them for the same
reason it governs a clone. Publishing and deleting stay `RW+` and remain
SSH-only.
### Bootstrap, corrected
The server reads its storage location and listener addresses before it can read
any git object — including before it can find `settings.git`, which lives under
`repos_dir`. Those cannot move into git without a chicken-and-egg problem, so
`--config` **stays**, reduced to bootstrap only: `repos_dir`, `http_bind`,
`ssh_bind`, and the host key location. Equivalent startup flags are a fine
addition but not a replacement.
Everything that is not needed to reach `settings.git` does move into it:
`site_title`, `max_release_size`, and the exposure axes above. `authorized_keys`
(`session.rs:417`, re-read on every auth attempt) is replaced by `keydir/`,
which also removes the per-attempt file read.
An init container creates `settings.git` with one key in `keydir/` and a minimal
`conf/access.conf` granting that principal `RW+` on `settings`. From there every
change is a push.
### Superseded text
The original section read: *"these become startup flags: `--repos-dir`,
`--http-bind`, `--ssh-bind`. They are config-file fields today
(`config.rs:7-18`); the existing `--config` flag and `server.toml` are removed.
`site_title` and `max_release_size` move into `settings.git`."* The removal of
`--config` is wrong for the reason above; the rest stands.
An init container creates `settings.git` with one key in `keydir/` and a minimal
`conf/access.conf` granting that principal `RW+` on `settings`. From there every
change is a push. `authorized_keys` (`session.rs:417`, re-read on every auth
attempt) is replaced by `keydir/`, which also removes the per-attempt file read.
## Not stolen
- The Perl implementation, and any runtime dependency on gitolite. `gitolite
access <repo> <user> <perm> [ref]` would work as an out-of-process oracle, but
a Perl install in the pod is a poor trade against a few hundred lines of Rust
for a single-tenant forge.
- `authorized_keys` regeneration and forced commands. We authenticate in-process
via russh (`session.rs:411`) and never hand off to a shell, so the file gitolite
generates has no role here.
- A unix user per install, and gitolite's `@all`-repo administrative commands.
## Testing
Behavioural, through the existing server harness. The harness supports one
repository and one client key at a fixed path, truncates its authorized-keys file
on generation (`tests/common/mod.rs:704`), and asserts success on every git
invocation (`:558`), so multiple repositories, multiple principals, a bare-repo
commit helper and a non-panicking push wrapper are prerequisites.
- An agent granted `RW refs/collab/` pushes a patch and its revisions, and a full
`git-collab sync` completes.
- The same credential pushing `refs/heads/main` is refused, and the refusal
message reaches the client.
- The same credential pushing `settings` is refused.
- Two keys with different basenames in `keydir/` are distinct principals; two
files sharing a basename across directories are the same principal.
- A push to `settings.git` whose config does not parse is rejected and the prior
config stays live.
- A push to `settings.git` that leaves no principal with `RW+` on `settings` is
rejected.
- An agent creates `agents/claude-a` under the wild pattern and has `RW+` on it;
a second agent cannot write it.
- A repository granting a principal nothing is invisible to it: clone, release
download and every HTTP route refuse identically.
- `private/tools.git` and `tools.git` resolve to distinct rule sets.
- A config push takes effect on the next request, with no restart.
## Open questions
**Enrolled keys or certificates.** This design assumes `keydir/`. Certificates
would suit short-lived agent credentials — expiry without a revocation commit,
no roster to store — at the cost of the readable key roster that makes `keydir/`
worth stealing. `russh` verifies a certificate before dispatching to the handler
(`server/encrypted.rs:788-810`), but dispatch is either/or and the current handler
implements only `auth_publickey` (`session.rs:411`), so certificates are rejected
by the default implementation until `auth_openssh_certificate` is implemented,
and pinning the CA is the handler's job — `verify_signature` only proves the
certificate is self-consistent.
The two compose: certificates for agents, `keydir/` for the operator's devices.
That is probably the answer, but it needs the certificate path built first.
## Out of scope
- Multi-tenant delegation and repository handover.
- Web UI authentication. HTTP remains anonymous and read-only
(`http/mod.rs:19-59`); all writes are over SSH.
- Signature verification of anything beyond the collab event DAG, which is
unchanged. Access control is by key identity at the transport, not by event
signature.