a73x

43f2bd64

Add a design for repository governance

a73x   2026-08-09 14:01

Commit message
Add a design for repository governance

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.