43f2bd64
Add a design for repository governance
a73x 2026-08-09 14:01
Commit message
docs/superpowers/specs/2026-08-09-repo-governance-design.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,307 @@ | |||
| 1 | # Repository Governance — Design | ||
| 2 | |||
| 3 | Date: 2026-08-09 | ||
| 4 | Status: Draft — open questions at the end must be settled before implementation | ||
| 5 | |||
| 6 | ## Purpose | ||
| 7 | |||
| 8 | Make the server's governance a property of the repositories it serves, rather | ||
| 9 | than of files on the host. Who may reach the server, who may move a repository's | ||
| 10 | canonical state, and who may contribute to it all become git objects: reviewable, | ||
| 11 | attributable, and revertable. | ||
| 12 | |||
| 13 | ## Deployment context | ||
| 14 | |||
| 15 | Single-tenant. One operator working from several machines, plus agent identities | ||
| 16 | that contribute code to individual repositories. The server runs as a pod; | ||
| 17 | out-of-band recovery is `kubectl exec`, and does not depend on the service being | ||
| 18 | governed. The web UI is internet-facing. | ||
| 19 | |||
| 20 | ## Threat model | ||
| 21 | |||
| 22 | The adversary is **a compromised agent credential**. It can open an SSH | ||
| 23 | connection and push. It must not be able to: | ||
| 24 | |||
| 25 | - move any repository's canonical state, | ||
| 26 | - widen its own access, | ||
| 27 | - reach a repository it was not granted, | ||
| 28 | - create repositories. | ||
| 29 | |||
| 30 | Governance is deliberately **readable** by anyone who can read the repository it | ||
| 31 | governs. Hiding it would require excluding a ref from the fetch advertisement, | ||
| 32 | and a contributor is entitled to know the rules it is subject to. | ||
| 33 | |||
| 34 | An adversary with write access to the storage directory is out of scope, because | ||
| 35 | the SSH host private key already lives there (`main.rs:38`, | ||
| 36 | `<repos_dir>/.server/host_key`). Anyone who can reach that directory can | ||
| 37 | impersonate the server outright. If the host key moves out of `repos_dir`, revisit | ||
| 38 | this — the internet-facing HTTP server runs in the same process and uid | ||
| 39 | (`main.rs:87-102`), so it is the shortest path to that directory. | ||
| 40 | |||
| 41 | ## Governing invariant | ||
| 42 | |||
| 43 | **Governance lives on refs its subject cannot write.** | ||
| 44 | |||
| 45 | A policy tracked in the working tree of the repository it governs fails this — | ||
| 46 | write access would become admin access. A policy on a ref that ordinary pushes | ||
| 47 | cannot touch satisfies it, and keeps governance in the same repository as the | ||
| 48 | thing governed, so there is no second repository to clone and no state to keep in | ||
| 49 | sync. | ||
| 50 | |||
| 51 | Satisfying the invariant requires the server to authorize **individual ref | ||
| 52 | updates**. | ||
| 53 | |||
| 54 | ## Model | ||
| 55 | |||
| 56 | ### Ref classes | ||
| 57 | |||
| 58 | Every push falls into exactly one class, and the class determines who may make | ||
| 59 | it. This replaces per-principal write flags entirely. | ||
| 60 | |||
| 61 | | Class | Refs | Who may write | | ||
| 62 | |---|---|---| | ||
| 63 | | Canonical | `refs/heads/*`, `refs/tags/*` | Delegates | | ||
| 64 | | Governance | `refs/collab/policy` | Delegates, threshold-signed | | ||
| 65 | | Collaboration | `refs/collab/issues/*`, `refs/collab/patches/*`, `refs/collab/archive/*` | Any reader | | ||
| 66 | | Contribution | `refs/for/<branch>` (virtual) | Any reader | | ||
| 67 | |||
| 68 | An unclassified ref is denied. `refs/collab/archive/*` is in the collaboration | ||
| 69 | class because `sync` pushes it (`sync.rs:226-243`) and `patch merge` writes to it | ||
| 70 | (`patch.rs:696`); omitting it would break both for every non-delegate. | ||
| 71 | |||
| 72 | A contributor never needs write access to a branch. A compromised agent | ||
| 73 | credential cannot move canonical state in any repository — there is no setting | ||
| 74 | that grants it. | ||
| 75 | |||
| 76 | ### Contribution via `refs/for` | ||
| 77 | |||
| 78 | `refs/for/<branch>` is virtual: no such ref exists in storage. A push to it is | ||
| 79 | converted server-side into a patch, or a new revision of the contributor's | ||
| 80 | existing patch for that branch. The refname names only the target branch, so a | ||
| 81 | contributor needs no local state beyond a remote. | ||
| 82 | |||
| 83 | The revision model, interdiff (`patch.rs:501`) and revision-anchored inline | ||
| 84 | comments (`state.rs:193`) already exist; this adds the push-side entry point. | ||
| 85 | Note that plain thread comments carry no revision (`state.rs:77`) — only inline | ||
| 86 | comments and reviews do. | ||
| 87 | |||
| 88 | ### Delegates | ||
| 89 | |||
| 90 | A repository's policy names its delegates and the threshold of delegate | ||
| 91 | signatures required to amend it. A repository begins with one delegate, its | ||
| 92 | creator, and a single-delegate amendment is self-accepting, so ordinary | ||
| 93 | single-operator use involves no review step. | ||
| 94 | |||
| 95 | ### Identity | ||
| 96 | |||
| 97 | One namespace throughout: SSH key fingerprints, the string `ssh-keygen -lf` | ||
| 98 | prints, prefixed with `key:`. The same string appears in every list. | ||
| 99 | |||
| 100 | ## Policy document | ||
| 101 | |||
| 102 | `refs/collab/policy` carries a single TOML document. | ||
| 103 | |||
| 104 | ```toml | ||
| 105 | delegates = ["key:SHA256:Gzb/mmBlyDmoWq3hMyPjb3SRWklPJ/yDAoD1K+ATvJA"] | ||
| 106 | threshold = 1 | ||
| 107 | |||
| 108 | visibility = "public" # omitted ⇒ private | ||
| 109 | description = "build tooling" | ||
| 110 | |||
| 111 | [ui] | ||
| 112 | anonymous = true # omitted ⇒ false; browse the web UI | ||
| 113 | |||
| 114 | [http] | ||
| 115 | anonymous_clone = true # omitted ⇒ false; clone and download releases | ||
| 116 | |||
| 117 | [access] | ||
| 118 | read = ["key:SHA256:…agent"] # omitted ⇒ empty; "*" means any authenticated principal | ||
| 119 | ``` | ||
| 120 | |||
| 121 | Every default is the closed value; an absent field denies. `RepoPolicy`'s current | ||
| 122 | serde defaults are the opposite (`repos.rs:129` returns `["*"]`, `repos.rs:125` | ||
| 123 | returns `Public`), so the governance policy type defines its own closed defaults | ||
| 124 | rather than deriving them. | ||
| 125 | |||
| 126 | `access.read` is the only per-principal list, and governs authenticated access. | ||
| 127 | `visibility`, `ui.anonymous` and `http.anonymous_clone` govern the anonymous HTTP | ||
| 128 | surface, where no principal exists — they are a separate axis, not a shorthand | ||
| 129 | for `access.read`. | ||
| 130 | |||
| 131 | A policy push is authorized against the **previous** revision's `delegates` and | ||
| 132 | `threshold`, never its own, so a policy cannot authorize itself. Signatures are | ||
| 133 | carried as commit signatures on the policy ref. | ||
| 134 | |||
| 135 | ## Enforcement | ||
| 136 | |||
| 137 | Authorization happens at two points, because the server learns the repository and | ||
| 138 | the verb at one moment and the refs being written at a later one. | ||
| 139 | |||
| 140 | ### At dispatch, once | ||
| 141 | |||
| 142 | Immediately after the requested path is resolved and before the command is | ||
| 143 | dispatched (`session.rs:506`), so every exec verb inherits it — `git-upload-pack`, | ||
| 144 | `git-receive-pack`, and each `collab-release` subcommand, including verbs added | ||
| 145 | later. | ||
| 146 | |||
| 147 | | Operation | Rule | | ||
| 148 | |---|---| | ||
| 149 | | Open SSH connection | Principal is a recognized identity (see Q1) | | ||
| 150 | | Clone / fetch | Delegate, or in `access.read`, or public and anonymous | | ||
| 151 | | Any push | May read the repository — ref classes are checked later | | ||
| 152 | | Release upload / delete | Delegate | | ||
| 153 | | Release list | May read the repository | | ||
| 154 | | Create a repository | See Q2 | | ||
| 155 | |||
| 156 | Release *download* has no SSH verb; it is an HTTP route | ||
| 157 | (`http/repo/releases.rs:72`) gated by `http.anonymous_clone`. The releases listing | ||
| 158 | page is gated by `ui.anonymous` — two different knobs on one page, retained | ||
| 159 | deliberately so a repository can publish artifacts without publishing a browsable | ||
| 160 | UI. | ||
| 161 | |||
| 162 | ### At receive, per ref | ||
| 163 | |||
| 164 | A server-managed `pre-receive` hook applies the ref-class table. It receives the | ||
| 165 | authenticated principal in its environment. Today `run_git_command` | ||
| 166 | (`session.rs:708`) spawns `git-receive-pack` with the server's inherited | ||
| 167 | environment and nothing per-request, so the principal plumbing is new, as is hook | ||
| 168 | installation — there are no hooks anywhere in the tree. | ||
| 169 | |||
| 170 | Two adjacent changes are required for a hook to work at all: the child's stderr | ||
| 171 | is currently piped and never read (`session.rs:711-716`), so a hook's rejection | ||
| 172 | text is discarded and a chatty hook can fill the pipe buffer and wedge the child. | ||
| 173 | |||
| 174 | ### Refusal behaviour | ||
| 175 | |||
| 176 | Dispatch-time refusals — unknown repository, unreadable repository — return an | ||
| 177 | identical `repository not found` reply and exit status, so a scoped credential | ||
| 178 | cannot map what else exists. | ||
| 179 | |||
| 180 | Ref-class refusals are necessarily distinguishable: by the time a hook runs, the | ||
| 181 | ref advertisement has already disclosed the repository's refs, and the rejection | ||
| 182 | surfaces as a per-ref status line. This is acceptable because the principal has | ||
| 183 | already passed the read check for that repository — it learns nothing it was not | ||
| 184 | entitled to. | ||
| 185 | |||
| 186 | ### Repository lookup key | ||
| 187 | |||
| 188 | The key is the resolved path relative to the storage directory with one trailing | ||
| 189 | `.git` removed, `/` separators preserved, matched byte-exactly. Nested paths are | ||
| 190 | keys in full: `private/tools.git` and `tools.git` are distinct repositories with | ||
| 191 | distinct policies. | ||
| 192 | |||
| 193 | Path resolution already accepts nested paths (`session.rs:967`), but `discover` | ||
| 194 | is non-recursive (`repos.rs:217`) and HTTP resolves by final component | ||
| 195 | (`repos.rs:241`), so nested repositories are currently unreachable over HTTP and | ||
| 196 | `tools.git` and `tools` in the same directory already collide there. Nested keys | ||
| 197 | therefore require reworking HTTP resolution and the URL scheme, not only the SSH | ||
| 198 | path. | ||
| 199 | |||
| 200 | ### Limits | ||
| 201 | |||
| 202 | Release storage is capped per repository in aggregate as well as per file. Only | ||
| 203 | the per-file cap exists today (`releases.rs:123`, one global `max_release_size`); | ||
| 204 | without an aggregate cap, filling the storage volume is a reachable way to force | ||
| 205 | the IO-error path in Failure modes. | ||
| 206 | |||
| 207 | ## Bootstrap | ||
| 208 | |||
| 209 | The server reads its storage location and listener addresses before it can read | ||
| 210 | any git object, so these become startup flags: `--repos-dir`, `--http-bind`, | ||
| 211 | `--ssh-bind`. They are config-file fields today (`config.rs:7-18`); the existing | ||
| 212 | `--config` flag and the file itself are removed. | ||
| 213 | |||
| 214 | Of the remaining `server.toml` fields, `site_title` and `max_release_size` become | ||
| 215 | git objects. `authorized_keys` holds a *path* to an OpenSSH-format file read on | ||
| 216 | every auth attempt (`session.rs:417`); whether that file survives at all depends | ||
| 217 | on Q1, and moving it into a git object also replaces the per-attempt reload. | ||
| 218 | |||
| 219 | Server-level authority, if it exists (Q2), is rooted at a pinned genesis commit | ||
| 220 | passed as `--genesis <oid>`. The admin chain is a ref whose each revision must be | ||
| 221 | signed by a threshold of the previous revision's admins; the pinned genesis | ||
| 222 | distinguishes the operator's chain from one an attacker substitutes. Key | ||
| 223 | rotation, adding a machine, and removing a compromised credential are signed | ||
| 224 | commits on that chain, and the out-of-band residue is one hash. | ||
| 225 | |||
| 226 | ## Failure modes | ||
| 227 | |||
| 228 | | Condition | Behaviour | | ||
| 229 | |---|---| | ||
| 230 | | Policy ref absent | Delegates only | | ||
| 231 | | Object read fails (IO) | Last known good, bounded; then closed | | ||
| 232 | | Document malformed | Deny that repository | | ||
| 233 | |||
| 234 | The stale window is bounded by both elapsed time and consecutive failures; | ||
| 235 | exceeding either closes the repository. Unbounded staleness would leave a | ||
| 236 | superseded policy in force after a revocation lands, with no visible symptom. | ||
| 237 | Both bounds need values before implementation. | ||
| 238 | |||
| 239 | Classification is by `git2::ErrorCode`, not by inspecting messages: a `NotFound` | ||
| 240 | on the ref or blob is an authoritative absence and closes; only a genuine IO | ||
| 241 | error is eligible for the stale path. | ||
| 242 | |||
| 243 | Server-level authority, where it exists, is evaluated before any repository | ||
| 244 | policy is read and is unaffected by all of the above. | ||
| 245 | |||
| 246 | ## Testing | ||
| 247 | |||
| 248 | Behavioural, through the existing server harness. The harness supports one | ||
| 249 | repository and one client key at a fixed path, truncates its authorized-keys file | ||
| 250 | on generation (`tests/common/mod.rs:704`), and asserts success on every git | ||
| 251 | invocation (`:558`), so multiple repositories, multiple principals, a bare-repo | ||
| 252 | commit helper and a non-panicking push wrapper are prerequisites. | ||
| 253 | |||
| 254 | - A contributor pushes `refs/for/main`; a patch revision appears. | ||
| 255 | - The same credential pushing `refs/heads/main` is refused. | ||
| 256 | - The same credential pushing `refs/collab/policy` is refused. | ||
| 257 | - The same credential pushing `refs/collab/issues/*` and `refs/collab/archive/*` | ||
| 258 | succeeds, and a full `git-collab sync` completes. | ||
| 259 | - A delegate pushes `refs/heads/main` and `refs/collab/policy` successfully. | ||
| 260 | - A policy push signed by a key named only in the *new* revision's delegates is | ||
| 261 | refused. | ||
| 262 | - A repository naming neither `access.read` nor the principal is invisible: | ||
| 263 | clone, release download and every HTTP route refuse identically. | ||
| 264 | - A policy document parsed from the empty string denies everything. | ||
| 265 | - `private/tools.git` and `tools.git` resolve to distinct policies. | ||
| 266 | - A malformed policy denies that repository. | ||
| 267 | - A policy push takes effect on the next request, with no restart. | ||
| 268 | - A rejected ref-class push surfaces the hook's message to the client. | ||
| 269 | |||
| 270 | ## Open questions | ||
| 271 | |||
| 272 | **Q1 · Enrolled keys or certificates.** | ||
| 273 | *Enrolled keys*: a git-controlled key set; revocation is a commit; credentials do | ||
| 274 | not expire. *Certificates*: a CA public key pinned at startup; credentials expire | ||
| 275 | without a revocation commit, which suits short-lived agents; no roster to store. | ||
| 276 | `russh` verifies a certificate before dispatching to the handler | ||
| 277 | (`server/encrypted.rs:788-810`), but dispatch is either/or — the current handler | ||
| 278 | implements only `auth_publickey` (`session.rs:411`), so certificates are rejected | ||
| 279 | by the default implementation until `auth_openssh_certificate` is implemented, and | ||
| 280 | pinning the CA is the handler's job since `verify_signature` only proves the | ||
| 281 | certificate is self-consistent. Decides whether a key set exists to be stored. | ||
| 282 | |||
| 283 | **Q2 · Whether server-level authority exists.** | ||
| 284 | It is needed only because repository creation consumes a shared namespace: two | ||
| 285 | principals cannot both create `tools.git`. *Keep it*: a genesis-rooted admin chain | ||
| 286 | on a ref in one otherwise-empty repository. *Drop it*: namespace repositories by | ||
| 287 | key, so creation never collides and nothing central is required, at the cost of | ||
| 288 | longer paths and changes to path resolution, HTTP resolution and the URL scheme. | ||
| 289 | Decides whether anything central survives, and interacts with the lookup key | ||
| 290 | above. | ||
| 291 | |||
| 292 | Q1 constrains Q2: with certificates and no key set, the admin chain is the only | ||
| 293 | remaining reason for a central repository. | ||
| 294 | |||
| 295 | **Q3 · Where `refs/for` conversion lives.** | ||
| 296 | A `pre-receive` hook can accept or reject a ref update; it cannot rewrite a | ||
| 297 | virtual ref into a patch. Converting the push requires code that owns the received | ||
| 298 | objects — either a hook that writes the patch DAG itself and then rejects the | ||
| 299 | virtual ref, or a receive-pack wrapper. This is unassigned and is the largest | ||
| 300 | unknown in the design. | ||
| 301 | |||
| 302 | ## Out of scope | ||
| 303 | |||
| 304 | - Multi-tenant delegation and repository handover. | ||
| 305 | - Web UI authentication. HTTP remains anonymous and read-only (`http/mod.rs:19-59`); | ||
| 306 | all writes are over SSH. | ||
| 307 | - Signature verification of anything other than the policy ref and the admin chain. | ||