a73x

c2d21097

Rewrite repo governance around a gitolite-style settings repo

a73x   2026-08-09 16:19

Commit message
Rewrite repo governance around a gitolite-style settings repo

Keys and access rules live in settings.git: one file per key with the
basename as the identity, ordered first-match rules with refexes, and wild
repo patterns so agents create their own namespaces without a central
allocator. A push that would install a broken config is rejected, which
removes the malformed-policy and bounded-staleness paths entirely.

Access control is a doorman, not a translator, so it keeps the client
workflow identical on every host. Combined with patches travelling under
refs/collab/*, a contributor's grant is one prefix and refs/heads/ never
appears in it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

docs/superpowers/specs/2026-08-09-repo-governance-design.md
Old New
@@ -1,189 +1,176 @@
1 # Repository Governance — Design 1 # Repository Governance — Design
2 2
3 Date: 2026-08-09 3 Date: 2026-08-09
4 Status: Superseded in part — needs revising against 4 Status: Draft — supersedes the hook/`refs/for`/in-band-policy design of the same
5 `2026-08-09-patch-revision-refs-design.md` 5 date. Depends on `2026-08-09-patch-revision-refs-design.md`.
6
7 That document adopts the design test "if a feature only works when the server is
8 `git-collab-server`, it is not a git-collab feature." Three parts of this design
9 fail it and are withdrawn: the `pre-receive` hook (Enforcement · At receive), the
10 `refs/for` contribution class and its conversion (Q3), and in-band policy on
11 `refs/collab/policy`, which was only enforceable by the hook. Governance returns
12 to a settings repository and per-repository read/write authorization. Q2 is
13 answered in the affirmative by that return; Q1 is unaffected.
14
15 The ref-class table goes down with the hook as an enforcement mechanism, but
16 survives as a description of what a host with per-ref control can apply — this
17 forge cannot. The threat model, the failure modes and the repository lookup key
18 carry into the revision unchanged.
19 6
20 ## Purpose 7 ## Purpose
21 8
22 Make the server's governance a property of the repositories it serves, rather 9 Govern the server the way gitolite does: a git repository holds the keys and the
23 than of files on the host. Who may reach the server, who may move a repository's 10 access rules, an admin pushes to it to reconfigure the server, and a push that
24 canonical state, and who may contribute to it all become git objects: reviewable, 11 would install a broken config is rejected rather than applied. Nothing is edited
25 attributable, and revertable. 12 on the host.
13
14 The addition is patches. Under a branch workflow, contribution requires write
15 access to `refs/heads/*`, so restricting a contributor means granting the
16 namespace and then carving `main` back out with deny rules — fragile, and easy
17 to get subtly wrong. With patches carried entirely under `refs/collab/*`
18 (revision-refs design), contribution is a single prefix grant and canonical
19 branches are never mentioned in a contributor's rule at all.
26 20
27 ## Deployment context 21 ## Deployment context
28 22
29 Single-tenant. One operator working from several machines, plus agent identities 23 Single-tenant. One operator across several machines, plus agent identities. The
30 that contribute code to individual repositories. The server runs as a pod; 24 server runs as a pod; out-of-band recovery is `kubectl exec`. The web UI is
31 out-of-band recovery is `kubectl exec`, and does not depend on the service being 25 internet-facing.
32 governed. The web UI is internet-facing.
33 26
34 ## Threat model 27 ## Threat model
35 28
36 The adversary is **a compromised agent credential**. It can open an SSH 29 The adversary is **a compromised agent credential**. It can open an SSH
37 connection and push. It must not be able to: 30 connection and push. It must not be able to move any repository's canonical
38 31 state, widen its own access, reach a repository it was not granted, or create
39 - move any repository's canonical state, 32 repositories outside its own namespace.
40 - widen its own access,
41 - reach a repository it was not granted,
42 - create repositories.
43
44 Governance is deliberately **readable** by anyone who can read the repository it
45 governs. Hiding it would require excluding a ref from the fetch advertisement,
46 and a contributor is entitled to know the rules it is subject to.
47
48 An adversary with write access to the storage directory is out of scope, because
49 the SSH host private key already lives there (`main.rs:38`,
50 `<repos_dir>/.server/host_key`). Anyone who can reach that directory can
51 impersonate the server outright. If the host key moves out of `repos_dir`, revisit
52 this — the internet-facing HTTP server runs in the same process and uid
53 (`main.rs:87-102`), so it is the shortest path to that directory.
54
55 ## Governing invariant
56 33
57 **Governance lives on refs its subject cannot write.** 34 An adversary with write access to the storage directory is out of scope: the SSH
35 host private key already lives there (`main.rs:38`,
36 `<repos_dir>/.server/host_key`), so that access permits impersonating the server
37 outright. If the host key moves out of `repos_dir`, revisit this — the
38 internet-facing HTTP server runs in the same process and uid (`main.rs:87-102`).
58 39
59 A policy tracked in the working tree of the repository it governs fails this — 40 Agents running as local worktrees on the operator's machine are a **different**
60 write access would become admin access. A policy on a ref that ordinary pushes 41 threat and are out of scope here. Such an agent holds the operator's key and
61 cannot touch satisfies it, and keeps governance in the same repository as the 42 shell; no server-side control can distinguish it. Its containment is that its
62 thing governed, so there is no second repository to clone and no state to keep in 43 deliverable is a patch the operator reads before merging, plus per-agent signing
63 sync. 44 keys for after-the-fact attribution. That is a review boundary, and it is the
45 real one.
64 46
65 Satisfying the invariant requires the server to authorize **individual ref 47 ## Design test
66 updates**.
67 48
68 ## Model 49 > If a feature only works when the server is `git-collab-server`, it is not a
50 > git-collab feature.
69 51
70 ### Ref classes 52 Access control passes this test. A contributor's workflow is byte-identical on
53 every host — `git-collab sync` — and on this forge a push they should not be
54 making is refused. That is what branch protection is, and it is not part of the
55 collaboration protocol.
71 56
72 Every push falls into exactly one class, and the class determines who may make 57 Server-side *protocol* work fails the test. `refs/for/<branch>` conversion,
73 it. This replaces per-principal write flags entirely. 58 in-band policy interpretation, and anything that rewrites what a client pushed
59 are out of scope permanently, not deferred.
74 60
75 | Class | Refs | Who may write | 61 The distinction is what the client must know. A doorman is invisible to it; a
76 |---|---|---| 62 translator is not.
77 | Canonical | `refs/heads/*`, `refs/tags/*` | Delegates |
78 | Governance | `refs/collab/policy` | Delegates, threshold-signed |
79 | Collaboration | `refs/collab/issues/*`, `refs/collab/patches/*`, `refs/collab/archive/*` | Any reader |
80 | Contribution | `refs/for/<branch>` (virtual) | Any reader |
81 63
82 An unclassified ref is denied. `refs/collab/archive/*` is in the collaboration 64 ## The settings repository
83 class because `sync` pushes it (`sync.rs:226-243`) and `patch merge` writes to it
84 (`patch.rs:696`); omitting it would break both for every non-delegate.
85 65
86 A contributor never needs write access to a branch. A compromised agent 66 `settings.git` is an ordinary repository on the server, governed by the same
87 credential cannot move canonical state in any repository — there is no setting 67 rules as any other, and reconfigures the server when pushed to.
88 that grants it.
89 68
90 ### Contribution via `refs/for` 69 ```
70 conf/access.conf repo blocks and access rules
71 keydir/<name>.pub one public key per file
72 keydir/<dir>/<name>.pub same, for a second device
73 ```
91 74
92 `refs/for/<branch>` is virtual: no such ref exists in storage. A push to it is 75 ### Identity
93 converted server-side into a patch, or a new revision of the contributor's
94 existing patch for that branch. The refname names only the target branch, so a
95 contributor needs no local state beyond a remote.
96 76
97 The revision model, interdiff (`patch.rs:501`) and revision-anchored inline 77 The identity is the **basename** of the key file, with directories ignored, as in
98 comments (`state.rs:193`) already exist; this adds the push-side entry point. 78 gitolite. `keydir/laptop/alex.pub` and `keydir/desktop/alex.pub` are both `alex`.
99 Note that plain thread comments carry no revision (`state.rs:77`) — only inline
100 comments and reviews do.
101 79
102 ### Delegates 80 This is the whole answer to one operator with several machines: adding a device
81 is adding a file, and revoking one is `git rm`. No syntax, and the diff is
82 legible in review.
103 83
104 A repository's policy names its delegates and the threshold of delegate 84 On the wire the principal is still the key fingerprint (`ssh_key_principal`,
105 signatures required to amend it. A repository begins with one delegate, its 85 `session.rs:171`). The server maps fingerprint to name at authentication time, so
106 creator, and a single-delegate amendment is self-accepting, so ordinary 86 names appear in config and fingerprints appear on the connection. An unmapped
107 single-operator use involves no review step. 87 fingerprint is not a principal and is refused.
108 88
109 ### Identity 89 ### Rules
110 90
111 One namespace throughout: SSH key fingerprints, the string `ssh-keygen -lf` 91 ```
112 prints, prefixed with `key:`. The same string appears in every list. 92 @admins = alex
93 @agents = claude-a claude-b
113 94
114 ## Policy document 95 repo settings
96 RW+ = @admins
115 97
116 `refs/collab/policy` carries a single TOML document. 98 repo tools
99 RW+ = @admins
100 RW refs/collab/ = @agents
101 R = @all
117 102
118 ```toml 103 repo agents/[a-z].*
119 delegates = ["key:SHA256:Gzb/mmBlyDmoWq3hMyPjb3SRWklPJ/yDAoD1K+ATvJA"] 104 C = @agents
120 threshold = 1 105 RW+ = CREATOR
106 ```
121 107
122 visibility = "public" # omitted ⇒ private 108 Line-oriented rather than TOML, deliberately. Evaluation is ordered first-match,
123 description = "build tooling" 109 and order is load-bearing; TOML tables do not express an ordered rule list
110 without an array-of-tables that reads far worse than the above.
124 111
125 [ui] 112 Semantics follow gitolite: a refex defaults to `refs/.*`, a bare name is
126 anonymous = true # omitted ⇒ false; browse the web UI 113 implicitly `refs/heads/`, refexes anchor at the start but not the end, `-` denies,
114 `@all` matches everything, and rules are gathered in order and filtered to the
115 accessing principal before the first match decides.
127 116
128 [http] 117 The permission set is `R`, `RW`, `RW+`, `C`. `C` is create, needed only for the
129 anonymous_clone = true # omitted ⇒ false; clone and download releases 118 wild-repo pattern below. Deletion is folded into `RW+` rather than given its own
119 letter; a single-tenant forge does not need the distinction, and it can be split
120 out later without changing any existing rule's meaning.
130 121
131 [access] 122 The `@agents` line above is the point of the whole design: an agent's grant is
132 read = ["key:SHA256:…agent"] # omitted ⇒ empty; "*" means any authenticated principal 123 one prefix, and `refs/heads/` never appears in it.
133 ```
134 124
135 Every default is the closed value; an absent field denies. `RepoPolicy`'s current 125 ### Wild repositories
136 serde defaults are the opposite (`repos.rs:129` returns `["*"]`, `repos.rs:125`
137 returns `Public`), so the governance policy type defines its own closed defaults
138 rather than deriving them.
139 126
140 `access.read` is the only per-principal list, and governs authenticated access. 127 `repo agents/[a-z].*` with `C = @agents` lets an agent create its own repository
141 `visibility`, `ui.anonymous` and `http.anonymous_clone` govern the anonymous HTTP 128 without a central allocator, and `RW+ = CREATOR` gives it full control of what it
142 surface, where no principal exists — they are a separate axis, not a shorthand 129 created and nothing else. Two agents cannot collide, because the pattern places
143 for `access.read`. 130 each in its own path.
144 131
145 A policy push is authorized against the **previous** revision's `delegates` and 132 This is what removes the need for a server-level authority over repository
146 `threshold`, never its own, so a policy cannot authorize itself. Signatures are 133 creation. Creation is a rule like any other.
147 carried as commit signatures on the policy ref.
148 134
149 ## Enforcement 135 ## Enforcement
150 136
151 Authorization happens at two points, because the server learns the repository and 137 Two points, because the server learns the repository and verb at one moment and
152 the verb at one moment and the refs being written at a later one. 138 the refs at a later one.
153
154 ### At dispatch, once
155 139
156 Immediately after the requested path is resolved and before the command is 140 **At dispatch**, immediately after path resolution and before the command is
157 dispatched (`session.rs:506`), so every exec verb inherits it — `git-upload-pack`, 141 dispatched (`session.rs:506`), so every exec verb inherits it — `git-upload-pack`,
158 `git-receive-pack`, and each `collab-release` subcommand, including verbs added 142 `git-receive-pack`, and each `collab-release` subcommand including ones added
159 later. 143 later. This resolves repository-level access: `R` to fetch, any of `RW`/`RW+`/`C`
160 144 to push, `RW+` to upload or delete a release, `R` to list releases.
161 | Operation | Rule | 145
162 |---|---| 146 **At receive**, per ref, via a server-managed hook that applies the refex rules.
163 | Open SSH connection | Principal is a recognized identity (see Q1) | 147 The hook is installed and owned by the server, is never authored by a user, and
164 | Clone / fetch | Delegate, or in `access.read`, or public and anonymous | 148 does no protocol work — it answers "may this principal write this ref" and
165 | Any push | May read the repository — ref classes are checked later | 149 nothing else. This is the gitolite `update` hook, and it is the kind of hook the
166 | Release upload / delete | Delegate | 150 design test permits.
167 | Release list | May read the repository | 151
168 | Create a repository | See Q2 | 152 Two adjacent changes are prerequisites. `run_git_command` (`session.rs:708`)
169 153 spawns `git-receive-pack` with the server's inherited environment and nothing
170 Release *download* has no SSH verb; it is an HTTP route 154 per-request, so passing the principal is new. And the child's stderr is piped and
171 (`http/repo/releases.rs:72`) gated by `http.anonymous_clone`. The releases listing 155 never read (`session.rs:711-716`), so a rejection message is currently discarded
172 page is gated by `ui.anonymous` — two different knobs on one page, retained 156 and a chatty hook can fill the pipe buffer and wedge the child.
173 deliberately so a repository can publish artifacts without publishing a browsable 157
174 UI. 158 ### Validation on push
175 159
176 ### At receive, per ref 160 A push to `settings.git` is validated before it takes effect: the config must
177 161 parse, every key file must be a well-formed public key, and the result must leave
178 A server-managed `pre-receive` hook applies the ref-class table. It receives the 162 at least one principal with `RW+` on `settings`. A push failing any of these is
179 authenticated principal in its environment. Today `run_git_command` 163 rejected, and the previous configuration stays live.
180 (`session.rs:708`) spawns `git-receive-pack` with the server's inherited 164
181 environment and nothing per-request, so the principal plumbing is new, as is hook 165 This deletes a failure mode rather than handling it. The superseded design had a
182 installation — there are no hooks anywhere in the tree. 166 malformed-policy path, a bounded-staleness window and two timeout values to
183 167 choose; none are needed, because a config that does not compile never becomes the
184 Two adjacent changes are required for a hook to work at all: the child's stderr 168 running config.
185 is currently piped and never read (`session.rs:711-716`), so a hook's rejection 169
186 text is discarded and a chatty hook can fill the pipe buffer and wedge the child. 170 Reading remains fallible at runtime — a genuine IO error on the config blob is
171 classified by `git2::ErrorCode`, not by message inspection, and closes the server
172 to writes rather than opening it. `NotFound` is an authoritative absence, not an
173 IO error.
187 174
188 ### Refusal behaviour 175 ### Refusal behaviour
189 176
@@ -191,71 +178,46 @@ Dispatch-time refusals — unknown repository, unreadable repository — return
191 identical `repository not found` reply and exit status, so a scoped credential 178 identical `repository not found` reply and exit status, so a scoped credential
192 cannot map what else exists. 179 cannot map what else exists.
193 180
194 Ref-class refusals are necessarily distinguishable: by the time a hook runs, the 181 Per-ref refusals are necessarily distinguishable: by the time the hook runs the
195 ref advertisement has already disclosed the repository's refs, and the rejection 182 advertisement has already disclosed the repository's refs. This is acceptable
196 surfaces as a per-ref status line. This is acceptable because the principal has 183 because the principal has already passed the read check for that repository.
197 already passed the read check for that repository — it learns nothing it was not
198 entitled to.
199 184
200 ### Repository lookup key 185 ### Repository lookup key
201 186
202 The key is the resolved path relative to the storage directory with one trailing 187 The resolved path relative to the storage directory with one trailing `.git`
203 `.git` removed, `/` separators preserved, matched byte-exactly. Nested paths are 188 removed, `/` separators preserved, matched byte-exactly. `private/tools.git` and
204 keys in full: `private/tools.git` and `tools.git` are distinct repositories with 189 `tools.git` are distinct repositories.
205 distinct policies.
206
207 Path resolution already accepts nested paths (`session.rs:967`), but `discover`
208 is non-recursive (`repos.rs:217`) and HTTP resolves by final component
209 (`repos.rs:241`), so nested repositories are currently unreachable over HTTP and
210 `tools.git` and `tools` in the same directory already collide there. Nested keys
211 therefore require reworking HTTP resolution and the URL scheme, not only the SSH
212 path.
213
214 ### Limits
215 190
216 Release storage is capped per repository in aggregate as well as per file. Only 191 Wild repositories make nested paths load-bearing rather than optional. Path
217 the per-file cap exists today (`releases.rs:123`, one global `max_release_size`); 192 resolution already accepts them (`session.rs:967`), but `discover` is
218 without an aggregate cap, filling the storage volume is a reachable way to force 193 non-recursive (`repos.rs:217`) and HTTP resolves by final component
219 the IO-error path in Failure modes. 194 (`repos.rs:241`), so nested repositories are unreachable over HTTP today and
195 `tools.git` and `tools` already collide there. HTTP resolution and the URL scheme
196 must be reworked as part of this.
220 197
221 ## Bootstrap 198 ## Bootstrap
222 199
223 The server reads its storage location and listener addresses before it can read 200 The server reads its storage location and listener addresses before it can read
224 any git object, so these become startup flags: `--repos-dir`, `--http-bind`, 201 any git object, so these become startup flags: `--repos-dir`, `--http-bind`,
225 `--ssh-bind`. They are config-file fields today (`config.rs:7-18`); the existing 202 `--ssh-bind`. They are config-file fields today (`config.rs:7-18`); the existing
226 `--config` flag and the file itself are removed. 203 `--config` flag and `server.toml` are removed. `site_title` and
204 `max_release_size` move into `settings.git`.
227 205
228 Of the remaining `server.toml` fields, `site_title` and `max_release_size` become 206 An init container creates `settings.git` with one key in `keydir/` and a minimal
229 git objects. `authorized_keys` holds a *path* to an OpenSSH-format file read on 207 `conf/access.conf` granting that principal `RW+` on `settings`. From there every
230 every auth attempt (`session.rs:417`); whether that file survives at all depends 208 change is a push. `authorized_keys` (`session.rs:417`, re-read on every auth
231 on Q1, and moving it into a git object also replaces the per-attempt reload. 209 attempt) is replaced by `keydir/`, which also removes the per-attempt file read.
232 210
233 Server-level authority, if it exists (Q2), is rooted at a pinned genesis commit 211 ## Not stolen
234 passed as `--genesis <oid>`. The admin chain is a ref whose each revision must be
235 signed by a threshold of the previous revision's admins; the pinned genesis
236 distinguishes the operator's chain from one an attacker substitutes. Key
237 rotation, adding a machine, and removing a compromised credential are signed
238 commits on that chain, and the out-of-band residue is one hash.
239 212
240 ## Failure modes 213 - The Perl implementation, and any runtime dependency on gitolite. `gitolite
241 214 access <repo> <user> <perm> [ref]` would work as an out-of-process oracle, but
242 | Condition | Behaviour | 215 a Perl install in the pod is a poor trade against a few hundred lines of Rust
243 |---|---| 216 for a single-tenant forge.
244 | Policy ref absent | Delegates only | 217 - `authorized_keys` regeneration and forced commands. We authenticate in-process
245 | Object read fails (IO) | Last known good, bounded; then closed | 218 via russh (`session.rs:411`) and never hand off to a shell, so the file gitolite
246 | Document malformed | Deny that repository | 219 generates has no role here.
247 220 - A unix user per install, and gitolite's `@all`-repo administrative commands.
248 The stale window is bounded by both elapsed time and consecutive failures;
249 exceeding either closes the repository. Unbounded staleness would leave a
250 superseded policy in force after a revocation lands, with no visible symptom.
251 Both bounds need values before implementation.
252
253 Classification is by `git2::ErrorCode`, not by inspecting messages: a `NotFound`
254 on the ref or blob is an authoritative absence and closes; only a genuine IO
255 error is eligible for the stale path.
256
257 Server-level authority, where it exists, is evaluated before any repository
258 policy is read and is unaffected by all of the above.
259 221
260 ## Testing 222 ## Testing
261 223
@@ -265,57 +227,44 @@ on generation (`tests/common/mod.rs:704`), and asserts success on every git
265 invocation (`:558`), so multiple repositories, multiple principals, a bare-repo 227 invocation (`:558`), so multiple repositories, multiple principals, a bare-repo
266 commit helper and a non-panicking push wrapper are prerequisites. 228 commit helper and a non-panicking push wrapper are prerequisites.
267 229
268 - A contributor pushes `refs/for/main`; a patch revision appears. 230 - An agent granted `RW refs/collab/` pushes a patch and its revisions, and a full
269 - The same credential pushing `refs/heads/main` is refused. 231 `git-collab sync` completes.
270 - The same credential pushing `refs/collab/policy` is refused. 232 - The same credential pushing `refs/heads/main` is refused, and the refusal
271 - The same credential pushing `refs/collab/issues/*` and `refs/collab/archive/*` 233 message reaches the client.
272 succeeds, and a full `git-collab sync` completes. 234 - The same credential pushing `settings` is refused.
273 - A delegate pushes `refs/heads/main` and `refs/collab/policy` successfully. 235 - Two keys with different basenames in `keydir/` are distinct principals; two
274 - A policy push signed by a key named only in the *new* revision's delegates is 236 files sharing a basename across directories are the same principal.
275 refused. 237 - A push to `settings.git` whose config does not parse is rejected and the prior
276 - A repository naming neither `access.read` nor the principal is invisible: 238 config stays live.
277 clone, release download and every HTTP route refuse identically. 239 - A push to `settings.git` that leaves no principal with `RW+` on `settings` is
278 - A policy document parsed from the empty string denies everything. 240 rejected.
279 - `private/tools.git` and `tools.git` resolve to distinct policies. 241 - An agent creates `agents/claude-a` under the wild pattern and has `RW+` on it;
280 - A malformed policy denies that repository. 242 a second agent cannot write it.
281 - A policy push takes effect on the next request, with no restart. 243 - A repository granting a principal nothing is invisible to it: clone, release
282 - A rejected ref-class push surfaces the hook's message to the client. 244 download and every HTTP route refuse identically.
245 - `private/tools.git` and `tools.git` resolve to distinct rule sets.
246 - A config push takes effect on the next request, with no restart.
283 247
284 ## Open questions 248 ## Open questions
285 249
286 **Q1 · Enrolled keys or certificates.** 250 **Enrolled keys or certificates.** This design assumes `keydir/`. Certificates
287 *Enrolled keys*: a git-controlled key set; revocation is a commit; credentials do 251 would suit short-lived agent credentials — expiry without a revocation commit,
288 not expire. *Certificates*: a CA public key pinned at startup; credentials expire 252 no roster to store — at the cost of the readable key roster that makes `keydir/`
289 without a revocation commit, which suits short-lived agents; no roster to store. 253 worth stealing. `russh` verifies a certificate before dispatching to the handler
290 `russh` verifies a certificate before dispatching to the handler 254 (`server/encrypted.rs:788-810`), but dispatch is either/or and the current handler
291 (`server/encrypted.rs:788-810`), but dispatch is either/or — the current handler
292 implements only `auth_publickey` (`session.rs:411`), so certificates are rejected 255 implements only `auth_publickey` (`session.rs:411`), so certificates are rejected
293 by the default implementation until `auth_openssh_certificate` is implemented, and 256 by the default implementation until `auth_openssh_certificate` is implemented,
294 pinning the CA is the handler's job since `verify_signature` only proves the 257 and pinning the CA is the handler's job — `verify_signature` only proves the
295 certificate is self-consistent. Decides whether a key set exists to be stored. 258 certificate is self-consistent.
296 259
297 **Q2 · Whether server-level authority exists.** 260 The two compose: certificates for agents, `keydir/` for the operator's devices.
298 It is needed only because repository creation consumes a shared namespace: two 261 That is probably the answer, but it needs the certificate path built first.
299 principals cannot both create `tools.git`. *Keep it*: a genesis-rooted admin chain
300 on a ref in one otherwise-empty repository. *Drop it*: namespace repositories by
301 key, so creation never collides and nothing central is required, at the cost of
302 longer paths and changes to path resolution, HTTP resolution and the URL scheme.
303 Decides whether anything central survives, and interacts with the lookup key
304 above.
305
306 Q1 constrains Q2: with certificates and no key set, the admin chain is the only
307 remaining reason for a central repository.
308
309 **Q3 · Where `refs/for` conversion lives.**
310 A `pre-receive` hook can accept or reject a ref update; it cannot rewrite a
311 virtual ref into a patch. Converting the push requires code that owns the received
312 objects — either a hook that writes the patch DAG itself and then rejects the
313 virtual ref, or a receive-pack wrapper. This is unassigned and is the largest
314 unknown in the design.
315 262
316 ## Out of scope 263 ## Out of scope
317 264
318 - Multi-tenant delegation and repository handover. 265 - Multi-tenant delegation and repository handover.
319 - Web UI authentication. HTTP remains anonymous and read-only (`http/mod.rs:19-59`); 266 - Web UI authentication. HTTP remains anonymous and read-only
320 all writes are over SSH. 267 (`http/mod.rs:19-59`); all writes are over SSH.
321 - Signature verification of anything other than the policy ref and the admin chain. 268 - Signature verification of anything beyond the collab event DAG, which is
269 unchanged. Access control is by key identity at the transport, not by event
270 signature.