Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Docs: rewrite the README and organize the guides

520470e19cd906bd6fff987e2d132d793ce56486 · Keenan Nemetz · 16d ago · parent c5902451

Docs: rewrite the README and organize the guides

Replace the long README with a landing page, split feature details into focused guides, and remove the roadmap and spike notes. Update comments that referenced them.

docs/roadmap.md −1221

@@ -1,1221 +0,0 @@
1 # Vole — Implementation Roadmap
2
3 Phased plan for building the forge described in [the README](../README.md).
4
5 ## Scope
6
7 **Single-tenant, self-hosted.** One Vole instance serves one team on one box. There is no
8 multi-tenancy, no hosted SaaS posture, and no organization/team hierarchy beyond a flat
9 list of users and repos.
10
11 **No federation.** Instances do not talk to each other. Read the README's "replicated
12 issues" bullet literally instead: because issues, change requests, and ACLs live in git
13 objects on sidecar refs, **every clone of a repo carries the full forge state**. Replication
14 is `git fetch`, not a protocol. State survives deleting the instance entirely.
15
16 These two constraints are what keep the project small enough to finish. They are assumed
17 throughout; see [Non-goals](#non-goals) for what they rule out.
18
19 ## Architectural decisions
20
21 | Decision | Choice | Why |
22 |---|---|---|
23 | State model | Git is authoritative; SQLite is a derived read model | Rebuildable index means storage is a swappable detail |
24 | Git integration | Shell out to the `git` binary | Hooks, CAS ref updates, and full `receive-pack` semantics for free; go-git lacks all three |
25 | Transport | SSH for writes (embedded server), smart HTTP for anonymous reads | One authenticated write path; `git` already speaks it, keys already exist |
26 | Metadata storage | Human-authored content and run records on `refs/heads/vole`; access rules in `data/users.db` | Content is portable and diffable, so it lives in git; rules are instance configuration read on every push, so they live beside the roster |
27 | Metadata writes | Compare-and-swap ref updates, retry on loss | No lock server, no lost updates |
28 | ACL enforcement | `pre-receive` only | One enforcement point; the web has no writes to gate |
29 | Binary layout | One static CGO-free binary, three modes (`serve`, `runner`, `hook`) | Drop-anywhere deployment |
30 | Index store | SQLite via `modernc.org/sqlite` | CGO-free; rebuildable, so swapping engines later is cheap |
31 | Identity | One roster database, `data/users.db`: accounts, every name held, public keys | Authoritative, not derived. SQLite enforces the two invariants that matter (a name is issued once, a key belongs to one account) and a backup is one file |
32 | Web authentication | None: the web is read-only | No password store, no login, no session tokens |
33 | Secrets | Instance key + SSH host key, `0600` on disk | A private key in a repo would be cloned and eventually pushed
34 | Config | TOML on disk, no database-backed settings | One box, one file, greppable |
35
36 ## Non-goals
37
38 Explicitly **not** built. Each is additive later; none is load-bearing for the concept.
39
40 - Multi-tenant isolation, orgs, nested teams, per-tenant resource quotas
41 - Federation, peer replication, cross-instance search, global entity IDs
42 - SSH transport (HTTP + PATs first; SSH keys are Phase 7 work if demanded)
43 - Container-isolated CI steps (shell steps only in v1)
44 - Per-repository custom domains and certificate issuance: an instance owns one domain
45 and terminates TLS in front of it, so sites are subdomains of that domain and
46 inheriting the certificate is the whole story
47 - Horizontal scaling, HA, multi-writer Postgres
48 - Kubernetes deployment story
49
50 ## Phases
51
52 Each phase ends with something runnable and a measurement that proves it, not a checklist
53 of merged PRs.
54
55 ### Phase 0 — Spikes and decisions — ✅ complete
56
57 Throwaway code in [`docs/spikes/`](spikes/README.md). Nothing merges. The point
58 is to kill bad assumptions while they're cheap.
59
60 | Spike | Result | Decision |
61 |---|---|---|
62 | [`ref-cas`](spikes/ref-cas/README.md) | Sharded + batched metadata writes: 6,966/s at 2k entities, 25,637/s at 20k, zero lost writes. One shared ref: **30/s** with 22 CAS retries per write, decaying to 511/s at 20k. | Metadata refs are **sharded (256)** and writes are **batched**. Non-negotiable. |
63 | [`smart-http`](spikes/smart-http/README.md) | Push 1 GiB at 36 MiB/s (incompressible) or 150 MiB/s (compressible); clone 118 MiB/s. Hook stderr propagates; abort leaves no ref, no orphans. Peak git RSS **1.13 GB** per 1 GiB push. | `git http-backend` subprocess confirmed. Push **size cap + concurrency semaphore** required. |
64 | [`tree-serving`](spikes/tree-serving/README.md) | Cold git read 1.19 ms p50 (12k req/s); warm cache 0.075 ms p50 (170k req/s) holding an 18.5 MiB site. | Serve **straight from trees**, no export step. Cache is required. |
65
66 Everything the roadmap assumed survived contact except one thing: an unsharded
67 metadata ref, which is now off the table permanently.
68
69 ### Phase 1 — It's a git server — ✅ complete
70
71 The unglamorous foundation. If this phase isn't solid, nothing above it is trustworthy.
72
73 - Bare-repo store on disk with a stable repo identity; create/rename/delete via CLI and web
74 - Smart HTTP read and write, auth via HTTP basic + personal access tokens
75 - Chunked request handling: spool to temp, set `CONTENT_LENGTH`, clean up on
76 error *and* abort, plus a startup janitor for orphans (spike B: git's default
77 client config produces a chunked request on any large push)
78 - Push size cap (default 1 GiB) and a push concurrency semaphore — a 1 GiB push
79 peaks at ~1.13 GB of git-child RSS, so this is a memory bound, not politeness
80 - Users, sessions, PATs (hashed at rest, scoped, revocable)
81 - ACL v1: owner / write / read (originally stored on `refs/vole/acl`; moved to the
82 instance database in Phase 4 — see the decision log)
83 - `pre-receive` + `update` hooks enforcing ACLs, protected branches, and object size limits
84 - Web UI: repo list, tree/blob browser, raw view, commit log
85 - ~~Instance signing key generated at first boot and persisted~~ — removed. Nothing
86 the instance writes is signed by the instance: run records are signed by the runner
87 that ran them (Phase 4 slice 4), and a signature only the instance can check is not
88 evidence. The host key is now the only secret on the box
89 - `vole index rebuild` implemented early and exercised by tests, even though the index has
90 little to hold yet
91
92 **Exit criteria, and how each is verified:**
93
94 | Criterion | Verified by |
95 |---|---|
96 | Authenticated push/fetch/clone over SSH | `TestPushAndCloneOverSSH`, `TestPushOverSSHThenReadOverBothTransports` |
97 | Unauthorized push rejected, with the SSH identity named | `TestReadOnlyUserCannotPush` — the message names the user, proving the key reached `pre-receive` |
98 | Rejected push leaves no partial ref update | ref compared before/after in `TestReadOnlyUserCannotPush` |
99 | A session cannot be used as a shell | `TestExecWhitelistRefusesShell` (incl. `sh -c`), `TestShellRequestRefused`, `TestRepoOutsideTheStoreIsRefused` |
100 | Unregistered key cannot connect | `TestUnknownKeyRejected` |
101 | `vole index rebuild` reproduces state after deleting the index | `TestRebuildIndexAfterDelete` |
102 | A roster backup is one consistent file | `TestRosterIsOneDatabaseFile` — a `VACUUM INTO` backup of a live instance opens as the same roster, with names, former names and keys |
103 | Private repos are invisible, not forbidden | `TestPrivateRepoIsInvisible` — 404 on web and HTTP, absent from the index, still cloneable over SSH |
104
105 **Withdrawn criterion:** "Identity survives without a side database". The roster is
106 now exactly one database file (`data/users.db`); what it used to promise — that the
107 roster is not lost with the index — is what the backup criterion above measures.
108
109 **Revision after Phase 1 shipped.** The first cut authenticated HTTP with
110 passwords and PATs, and the web had logins and settings pages. That was
111 replaced, not extended: passwords were dropped, writes moved to SSH, and the web
112 became read-only. The result is smaller than what it replaced — the push path
113 over HTTP (chunked spooling, spool janitor, push semaphore, byte cap), the auth
114 package, and every web mutation handler were deleted. Two things replaced the
115 push cap honestly: `receive.maxInputSize` in each repo, and the per-blob check
116 that was already in `pre-receive`.
117
118 Deviations from the original plan, and why:
119
120 - **`pre-receive` only, no `update` hook.** One enforcement point with
121 all-or-nothing semantics; it is what makes "a rejected push moves no refs"
122 true by construction.
123 - **Only branches and tags are writable.** A push to `refs/notes/*`, `refs/vole/*` or
124 any other namespace is refused. `refs/vole/*` used to have a rule of its own because
125 the ACL lived there, where a client write would have been a self-granted admin; when
126 the rules moved to `data/users.db` the namespace emptied and the special case went
127 with it.
128 - **The roster is a SQLite database, not a repository and not a key directory.**
129 Its first shape was `refs/heads/main` in `data/system.git`; the second was
130 `data/trusted_keys/<user>.keys` files. The git version paid for a commit, a CAS
131 retry and a parse step per roster read to store a handful of rows, and the
132 directory version could not hold the stable account id that ACLs and approvals
133 key on without becoming a database in spirit. `data/users.db` holds the same
134 facts (id, name, every name ever held, status, keys) with the invariants as table
135 keys, and the client read that `system.git` provided is now the `vole-roster` SSH
136 verb. See the decision log.
137 - **No WebAuthn.** It requires a secure context, which would have pulled TLS
138 forward from Phase 6 to authenticate a web surface that no longer accepts
139 writes.
140 - **Exec refusals are answered on the channel**, not by rejecting the request,
141 so the client can read the reason instead of `exec request failed on channel 0`.
142
143 ### Phase 2 — Metadata layer and issues — ✅ complete
144
145 **The design changed before building.** Phase 2 was written around server-side
146 writers: sharded metadata refs, batched CAS, counters assigned by the server.
147 That needs an authenticated write surface, and the web is now read-only. Issues
148 are therefore **push-based**: content lives on a branch, written by the people who
149 file them, validated at the push boundary. The Phase 0 sharding work is not wasted
150 (change requests will still want it), but nothing on the issue path depends on it.
151
152 Shipped:
153
154 - **Format**: `issues/<ulid>/issue.md` — a frontmatter head (title, state, author,
155 created, labels) followed by the body as markdown — and
156 `comments/<ulid>.md`, on `refs/heads/vole`. ULIDs are generated by the writer, so
157 there is no counter to assign. It is a branch rather than a custom ref so that a
158 plain `git clone` carries the issues, and issues share it with change requests
159 because both are human-authored and low-frequency — one push, one fetch, one
160 validation path. Machine-written state (CI runs) uses this ref as well, since Phase 4
161 slice 6: the runner commits the finished attempt's record under `runs/` and pushes it
162 with the artifacts the build published, because a result a clone is meant to carry is
163 a result worth committing — and the gate checks who may write that prefix rather than
164 refusing the prefix outright.
165 - **Branch rules**, enforced in `pre-receive`: only `issues/` and `requests/` may
166 appear in the tree; history must be linear (no merge commits); force-push
167 requires repo admin and is written to the audit log.
168 - **Validation in `pre-receive`**: ULID directory names, required fields, valid
169 states and labels, comment frontmatter, and no paths outside the format. Every
170 problem is reported in one rejection, with the offending path named.
171 - **Derived index**: issue rows refreshed when the issues ref moves, plus the
172 existing full rebuild. The detail page reads from git so bodies are never stale.
173 - **Read-only rendering**: list, thread, GFM markdown sanitised with bluemonday —
174 issue bodies arrive by push, so they are untrusted input on a public page.
175 - **`vole issue new|comment|close|reopen|list|show`**: sugar over the same tree.
176 It fetches the issues branch first so its commit is a fast-forward, validates
177 locally so a bad issue never costs a round trip, then pushes.
178 - **Cross-references**: `#<ulid>` renders as a link, except inside fenced code.
179 - **Backlinks**: commits whose message mentions an id are listed on the issue,
180 via `git log --grep`. No second index to keep in sync.
181 - **Filtering and search**: state, label, and a LIKE search over title and body,
182 backed by an `issue_labels` table and an indexed `body` column.
183
184 Follow-ups:
185
186 - **Notifications are deliberately not built.** There is no user identity on a
187 read-only web, so "in-app" has nobody to notify, and email needs subscriptions
188 that would themselves need a store. The natural fit for this architecture is a
189 per-repo Atom feed of issue and commit activity, which needs no auth — worth
190 doing when someone asks, instead of building a notification system nobody can
191 log into. The shape it should take when it is wanted: one feed per repo over
192 tags *and* issues/comments, because the audience with no alternative is the
193 person consuming artifacts, not the person who already has a clone.
194 - ✅ **Assignees are first-class.** `assignees:` in the frontmatter was parsed and
195 written but went nowhere; it is now validated, filterable and clickable. An
196 assignee is held to the roster's account-name rule rather than the label rule —
197 it names a person, and a value that could never be an account is a typo, not a
198 new kind of label — but it is **not** roster-checked at push time: the hook has
199 no business failing a push because an assignment names somebody who has not been
200 created yet, and a name in frontmatter is a claim in the same way `author` is.
201 It is stored as written rather than resolved to an account id, so the file stays
202 readable in a plain clone and an assignment keeps meaning the same person after
203 a rename, since names are never reissued. `vole issue new -a`, `assign`,
204 `unassign`, `list --assignee`, `%(assignees)`, the index's `issue_assignees`
205 table and the web's filter links all read that one field.
206 - ✅ **Comments are editable and deletable, by their author.** Today the only
207 options were "append another comment" or rewrite history and force-push. An
208 edit is a normal commit that rewrites the comment's file in place — so the path,
209 the id and one `git log -p -- issues/<id>/comments/<cid>.md` all still work —
210 and a delete removes the file, leaving the text in history. Neither adds an
211 `edited` field, for the reason `updated` was dropped: the commit carries the
212 time. `pre-receive` compares the author on the **old** blob against the pushing
213 account, so only that person may change or delete a comment, and rewriting the
214 author line in the same push does not launder it; an admin may override, audited,
215 because a thread written by someone who has left is otherwise unrepairable.
216 `vole issue edit-comment`, `rm-comment` and `edit` (title, body, labels,
217 assignees) plus `issue comment` printing its id make the whole loop usable from
218 the CLI, and each comment shows its id on the web. The issue file itself is
219 deliberately **not** authorship-checked: state, labels and assignees are shared
220 fields, and refusing a maintainer's close or assignment would make them useless.
221 Deleting is not redaction — that remains an admin force-push.
222
223 Exit criteria: a user with an editor and a key files, comments on and closes an
224 issue without installing Vole; an invalid tree is rejected with the offending path
225 named and the branch unmoved; deleting the index loses no issues.
226
227 ### Phase 3 — Change requests — ✅ complete
228
229 ✅ **Done.** A change request is a directory on `refs/heads/vole`:
230
231 ```
232 requests/<ulid>/request.md title, author, base branch, head commit, state, then the body
233 requests/<ulid>/approvals/<principal>.md base + head + created, no body
234 requests/<ulid>/comments/<ulid>.md author + created, then the discussion
235 ```
236
237 - **Approvals are bound to one commit and one branch.** `git push` is the merge, so the
238 gate is `pre-receive` and there is no write surface to authenticate. An approval names
239 the exact head it reviewed; push a new head and the approvals stop matching.
240 - **Two push shapes are accepted** for a protected branch: the new tip *is* the approved
241 head, or the new tip is a merge whose parents are the branch tip and the approved head
242 (either order). "Approved head is an ancestor of the new tip" is deliberately refused —
243 it would let anyone append unapproved commits and land them together.
244 - **Admins bypass; everyone else needs the approvals.** `vole acl approvals <repo> <n>`
245 sets the count; `0` restores the old admin-only behaviour.
246 - **An approval can only be written by the person it names.** Otherwise a writer could
247 commit `approvals/<someone-else>.json` and approve on their behalf; the hook rejects any
248 newly added approval whose name is not the pushing principal. Self-approval is refused
249 too, at the CLI and again in validation.
250 - **View**: list and detail pages, current versus stale approvals, and the file list the
251 request would change.
252 - **Limitation, stated plainly**: ownership is enforced at push time, not verifiable at
253 read time. Someone holding a clone can see that `approvals/keenan.json` exists but not
254 prove the server saw keenan write it; that rests on `data/audit.log` and on the gate
255 having refused forgeries. Publishing signed attestations (Phase 7) is what would make
256 it verifiable offline.
257
258 ✅ **Discussion, since this phase closed.** A request can be talked about without
259 inventing a format: a comment on a request is the *same file shape* as a comment on
260 an issue — `requests/<ulid>/comments/<ulid>.md`, author and created in frontmatter,
261 body as markdown — so one reader, one writer and one ownership rule cover both.
262 `vole request comment <id> -m` pushes it like everything else, `edit-comment` and
263 `rm-comment` are the author's own words to change (an admin may repair a thread,
264 audited), and the page renders the thread with an anchor per comment, so a link to
265 one remark is a URL. Until this landed the only ways to say something about a
266 request were editing its body or approving it.
267
268 ✅ **Line anchors, since this phase closed.** A comment may name the line it is
269 about, on either side of the diff:
270
271 ```
272 path: server/hook/hook.go
273 side: new # new = the head's file, old = the merge base's
274 line: 412
275 blob: 3f2a… # the id of the content reviewed
276 ```
277
278 `vole request comment <id> -m "…" --path f --line n [--side old]` computes the blob
279 from the clone (refusing a line past the end of the file, or a head that is not
280 there), and the request page marks the line with a link to the comment. The anchor
281 is on the **blob**, not on a commit or a line number, and that is what makes
282 "survive a rewrite" true: the same file at a later head has the same blob id, so a
283 comment stays current while the file is untouched and becomes *stale* — labelled,
284 marker removed — the moment it changes, rather than sliding to a line that now says
285 something else. The page answers that question itself by resolving the blob at the
286 head (or the merge base) and comparing, so nothing has to be verified at push time:
287 an anchor that lies can only make itself stale.
288 - The *collapsible* part of the per-file diff. The diff itself landed in Phase 7
289 slice 1 (three-dot range, per-file hunks, line numbers), and required status
290 checks landed in Phase 4 slice 3, so both are struck off the original list
291 rather than still waiting here.
292
293 ✅ **Conflict detection, since this phase closed.** `git merge-tree --write-tree
294 --name-only` merges in memory and names the paths that conflict, so the request
295 page says "this branch no longer merges into main cleanly: `code.txt`" before
296 anybody approves, and `vole request show` prints the same line from a clone. It is
297 **surfaced, not gated**: the roadmap asked for detection before the push, and a gate
298 would only ever be right at the instant it ran, since the base can move after an
299 approval. It is also only claimed when the question could be asked — a missing base
300 branch or a head that was never pushed produces silence, because a page that says
301 "clean" for a commit it could not load is worse than one that says nothing. The
302 same view now states the requirement ("0 of 1 required approval(s)"), which used
303 to be a number the reviewer had to remember.
304
305 **Exit criteria:** met. Verified end to end: a
306 writer cannot land on a protected branch without approvals, an amended head invalidates a
307 prior approval, an approval for `main` cannot be repointed at another branch, a
308 self-approval or impersonated approval is refused, an admin bypass works, and each merge
309 is written to the audit log with the request id and approvers. Review comments were the
310 one item outstanding when this was written; they landed in the two sections above, so
311 nothing on the original list is left.
312
313 ### Phase 3.5 — the roster — ✅ complete
314
315 ✅ **Done.** `data/users.db` is the user roster: one account per row with a stable
316 id, a mutable name, a status and its SSH public keys. `user_names` holds every name
317 an account has ever held, so SQLite itself guarantees a name is issued once;
318 `user_keys` is keyed by fingerprint, so a key cannot belong to two accounts.
319
320 - Accounts have a stable id; names are mutable, and a name is never issued twice, so
321 a new account cannot inherit a retired one's ACL entries or approvals.
322 - ACL entries and approval files are keyed by account id, so `vole user rename` is a
323 one-row change that disturbs nothing else.
324 - The roster is polled every five seconds, so `add-key` and `retire` take effect
325 without a restart.
326 - A clone has no copy of the roster. The CLI asks the instance for the id/name map
327 over the SSH key it already pushes with (`vole-roster`, one tab-separated line per
328 active account), so name resolution needs no endpoint and no second credential.
329 - `vole user backup <path>` takes a consistent copy with `VACUUM INTO` — copying
330 `users.db` by hand while the instance runs can miss the `-wal`. Measured:
331 `TestRosterIsOneDatabaseFile` backs up a live instance's roster, opens the copy and
332 resolves a renamed account and its key from it.
333
334 **The system repository was withdrawn, not deferred.** `data/system.git` held the
335 roster on `refs/heads/main` with a per-account `users/<ulid>/meta.json`, plus
336 git-served read access for clients. It earned none of its parts: every roster change
337 was a commit on a second repository that existed only for this, the client read was a
338 `git fetch` of a repo that held nothing else, and the same invariants (one name, one
339 key) fell out of table keys instead of a parse step. The instance landing page it was
340 also meant to host is served from trees by Phase 6, so nothing else depended on it.
341
342 ### Phase 4 — CI/CD — ✅ complete
343
344 ✅ **Slice 1 — workflow format and validation.** `.vole/workflows/*.yaml` is parsed and
345 validated in `pre-receive` for any push that touches it, so an invalid file fails the
346 push that introduced it with every problem named: unknown keys, missing `run`, bad job
347 names, timeouts beyond the cap, files in subdirectories, non-YAML names. `vole workflow
348 check` runs the same validator locally. Workflows carry `version`, branch filters
349 (globs, default all) and jobs of shell steps with per-step timeouts.
350
351 ✅ **Slice 2 — queue and runner.** `post-receive` spools one file per accepted ref
352 update; the server ingests them into `data/runs.db` (unique on repo+ref+sha+workflow+job,
353 so re-reading the spool never double-queues) and pins each run to the commit and the
354 workflow blob it came from. `vole runner` connects over SSH with the user's key — no
355 token — long-polls for a lease, checks the commit out, runs the steps with per-step
356 timeouts, heartbeats while it works, uploads the attempt's log to the object store
357 and reports the exit code and the log's object id. A lease that expires returns the
358 job to the queue; a job that kills five runners is failed.
359
360 ✅ **Slice 3 — checks on the gate.** A protected branch now needs green checks for the
361 approved head as well as approvals (`vole acl checks <repo> on|off`, default on; a
362 repository with no workflows is unaffected). The gate evaluates the approved head rather
363 than a merge commit, since CI runs on what was reviewed. Runs are shown on the repository
364 page and on each change request.
365
366 ✅ ~~**Slice 4 — the runner signs its result.**~~ **Removed** — see [the trust model](#the-trust-model)
367 below. The signature was superseded by boundary attribution: it restated the SSH handshake
368 for a verifier this deployment does not have, and it was the only place in the system with
369 a second, crypto-based answer to "who did this". The text is kept for the reasoning:
370
371 *The party that did the work signs it:* at
372 finish the runner signs a claim naming the run, the repository, the ref, the SHA, the
373 workflow blob, the job, the digest of the pinned steps, the exit code, the log's
374 `{oid, size}`, the artifacts it published and its own finishing time, and the server
375 records the claim and the signature verbatim. The signer is fixed by the channel, not
376 by the claim: the account comes from the SSH key that opened the connection, and the
377 signature must verify under one of *that* account's keys, so a runner can sign with a
378 different key from the one it connected with but not as somebody else. An unsigned
379 finish, a signature by another account's key and a signature over a different claim
380 are all refused, and nothing is recorded before the claim verifies — half a record is
381 worse than none. The run page says `attested by <name> · key <fingerprint>`, and
382 `vole runner` pings first and refuses to start work if the key it is about to sign
383 with is not one its account holds (learning that after a ten-minute build is a wasted
384 build). Steps are pinned at enqueue into a digest so the claim covers what ran, not
385 merely which workflow file it came from.
386
387 A signature adds accountability, not truth: a runner can still lie about an exit code,
388 and the record says what it says, attributed to a key somebody is answerable for. What
389 it does buy is that the record is attributable to a party other than the instance,
390 which no instance-signed record could be.
391
392 **Revised by slice 6:** the separate claim is gone. The runner signs the *commit* that
393 carries its record, so there is one statement instead of two and nothing the server
394 assembles on a runner's behalf. Everything this slice established — the key is the
395 account's, the channel decides who is reporting, an unsigned result is refused — is
396 checked at the push boundary instead of the finish message.
397
398 ✅ **Slice 5 — only allowed runners green-light a branch.** Signing made a result
399 attributable; this makes the attribution matter. `acl.runners` is an allowlist of
400 accounts whose CI results count for a repository, and the gate's rule is: a green run
401 counts only when its attestation names an account on that list. Admins are implicit —
402 they can rewrite the list, and a protected branch already lets them land anything with
403 no checks at all, so restricting them here would be theatre. Everyone else, a writer
404 running CI on their laptop included, gets their result recorded and *not* counted, and
405 the run page says which: `counts as a check` or `does not count as a check: wilbur is
406 not a runner for this repository`. The same list decides who may lease a job, since
407 running a job executes the repository's code — so the answer arrives before the build,
408 not after it.
409
410 `vole acl runners <repo>` shows the list and `<name>...` replaces it, the same shape as
411 `acl protect`. `--add` and `--remove` edit one name at a time and `--clear` empties it,
412 following git's way of editing a list (`config --add`, `config --unset-all`,
413 `remote set-url --add`) instead of inventing signs.
414
415 ✅ ~~**Slice 6 — the runner publishes its result, as a signed commit.**~~ **Removed** together
416 with slice 4; the record is still a commit the runner pushes, but it is not signed, and what
417 `pre-receive` checks is that the record names the account that pushed it. The text is kept
418 for the reasoning:
419
420 *A finished
421 attempt is committed to `refs/heads/vole` by the **runner**:
422 `runs/<run id>/<attempt>/record.md` (run, repo, ref, sha, workflow, workflow blob,
423 job, steps digest, exit, finish time, signer, key) plus an LFS pointer for the log and
424 one per published artifact. The commit is signed by the runner's SSH key, and that
425 signature *is* the attestation: it covers the tree, the parents and the message, so
426 there is no second statement to sign and no claim format that can drift from the files
427 it describes. The instance signs nothing and creates nothing on a runner's behalf —
428 the same rule that deleted the instance signing key, applied to results.
429
430 It rides the run's **last push**: the record and any release pointers the build
431 published are one commit, so a run costs one push, and a release cannot carry bytes
432 whose build has no record. Pointer files are the format the sweep already marks and
433 `pre-receive` already validates, so portability cost no GC rule, no new store code,
434 and no new object check — a log the store does not have fails the same push a pointer
435 hygiene check has always failed.
436
437 `pre-receive` decides who may report, and it asks three things of an added record: the
438 pushing account is on the repository's runner allowlist (`acl runners`, admins
439 implicit — the same list that decides who may lease a job), the commit is signed by a
440 key of that account and the record names that key, and the record describes the job
441 the queue handed *that* account — same run, same attempt, same commit, workflow, job
442 and steps digest. The last rule is what stops an allowed runner from reporting a green
443 result for a commit nobody asked it to build, or inventing attempt 99 for a finished
444 run. Records are append-only: an edit or a deletion is refused, with no admin
445 override, because a record that can change is not evidence.
446
447 `runs.db` keeps what has not happened yet (queued and leased jobs, which have no
448 record because they have not happened) and mirrors finished runs as a derived read
449 model — signer, key, commit id, log, artifacts — so a page or a gate needs no tree
450 walk. `vole index rebuild` reconstructs that from the branch, so deleting the queue
451 loses only in-flight work.
452 (Measured through the binary: a real run finished, `git clone --bare` carried
453 `runs/<id>/1/record.md`, and `git verify-commit` against the roster's key accepted the
454 commit while an unsigned one was refused; a writer who is not a runner was refused by
455 `pre-receive` naming the allowlist; `runs.db` deleted, `vole index rebuild` restored
456 the row, the attestation and the log object id.)
457
458 ✅ **Slice 7 — the queue follows the refs, and a lease waits instead of polls.** The
459 push path used to be one statement: a spool file the hook writes after the refs move,
460 which it is allowed to fail without failing the push. A crash or a full disk in that
461 window left a live commit with no build and nothing that would ever notice, and
462 `rm data/runs.db` lost every queued job while this package's own header claimed only
463 in-flight work was lost. Both are now the same question with the same answer: a ref tip
464 is the durable statement that a commit is live. `ref_cursors` records the tip each ref
465 had when its work was queued, and `reconcileRefs` compares the refs against it once at
466 startup and every 30 seconds. The spool stays what it should be — the sub-second path,
467 not the promise. The trade is stated rather than discovered: a tip no cursor has
468 recorded is work owed, so a queue that was deleted (or an instance upgrading onto this)
469 queues its current tips once, and the unique index stops a tip that already ran from
470 running twice once `vole index rebuild` has restored its row from the record on the
471 branch.
472
473 An idle runner also stopped costing anything. The lease takes a `wait` and the session
474 parks on a wake-up channel that a committed enqueue closes — taken *before* the lease
475 attempt, so a run queued in between cannot be missed — and the account's eligible
476 repositories are reused for the roster's five-second window instead of being re-derived
477 on every request, which was O(repos) per idle runner every two seconds.
478
479 A newer tip cancels the queued builds of the older one (`StatusCanceled` finally has a
480 writer, and only queued rows: a running job is never interrupted), and the lease orders
481 by in-flight count per repository before age, so one repository's burst cannot occupy
482 every runner while a quiet repository waits.
483
484 (Measured: `TestReconcileQueuesATipTheQueueHasNeverSeen`,
485 `TestReconcileRebuildsAQueueThatWasDeleted` — `runs.db` and its WAL deleted, the live
486 tip queued again — `TestReconcileIgnoresTheMetadataBranch`,
487 `TestReconcileDropsTheCursorOfADeletedBranch`,
488 `TestALeaseWaitsForWorkInsteadOfPolling` (a lease parked for 30 s returns the job
489 within 5 s of it being queued), `TestALeaseWithoutAWaitAnswersAtOnce`,
490 `TestQueuingWorkWakesTheParked`,
491 `TestCancelSupersededOnlyTouchesWaitingRunsOfTheSameRef`,
492 `TestLeaseServesTheLeastBusyRepoFirst`, `TestANewerTipCancelsTheQueuedBuildOfTheOlderOne`.)
493
494 ✅ **Slice 8 — runner status on the web.** `runner_sessions` is presence rather than
495 history: one row per connected channel, written by events a runner already sends
496 (connect, lease, heartbeat, finish), dropped when the channel closes and forgotten
497 after ten silent minutes — the only trace a killed process leaves. `GET /runners` shows
498 who is connected, what each is building (linked to the run) or "waiting for work", and
499 the queue depth per repository, which is the page somebody needs when a build does not
500 start. It follows the clone rule like every other page: a session building a private
501 repository is not shown at all and that repository's queue is not counted, because
502 naming either would confirm the repository exists.
503 (Measured: `TestARunnerSessionIsVisibleWhileItIsConnected`,
504 `TestRunnersPageShowsTheRunnerHoldingAJobAndTheQueueDepth`,
505 `TestRunnersPageKeepsAPrivateRepositoryOut`.)
506
507 ✅ **Slice 9 — the record names the machine.** A finished attempt says which operating
508 system and architecture ran it, in `record.md` beside the exit code and the signer, so
509 the two fields ride the same signature as everything else: a result a clone carries now
510 says what kind of machine produced it, not only which job it was. This is what makes
511 "one macOS runner in a Linux pool" a question a reader can answer after the fact, and it
512 is the first half of the targeting work below. Records written before the field existed
513 still read — the keys are optional and an absent machine writes nothing at all rather
514 than an empty line — because a record is never rewritten and history has to stay
515 readable. `runs.db` mirrors the pair the way it mirrors the log and the artifacts: only
516 while the runner still holds the lease, so an overtaken runner leaves no claim behind,
517 and `vole index rebuild` restores it from the record.
518 (Measured: `TestRunRecordIsPortable` — the machine survives the record on the branch,
519 the row, the rebuild and the page; `TestRecordWithoutMachineStillReads` and
520 `TestMachineIsRecordedOnlyWhileTheLeaseIsHeld` pin the two edges.)
521
522 **The Go version was dropped from this slice, deliberately.** The roadmap asked for "the
523 runner's Go version" beside the OS and architecture, and it does not earn its place:
524 `runtime.Version()` names the toolchain the *runner binary* was compiled with, which is
525 not the Go that builds anything in a job — a step running `go build` uses whatever is on
526 `PATH` in the checkout, usually a different install. A number that looks authoritative,
527 sits inside a signed record and is routinely wrong about the thing a reader would assume
528 it describes is worse than no number. OS and architecture survive the same objection
529 because they describe the machine, which is the fact this slice was actually for.
530
531 ✅ **Slice 10 — one repository may be bounded.** `max_jobs_per_repo` caps how many of
532 one repository's jobs run at once; unset, there is still no cap. The distinction that
533 made this worth a knob: the lease already *orders* by in-flight count, so a burst cannot
534 take the whole fleet — but ordering is not a bound, and what an operator wants when one
535 repository's build saturates the instance is a number. A repository at its cap is
536 *skipped*, not blocking, so the other repositories an account may run are still served
537 and `nil` from the lease now means "work exists, but not for you yet". That second
538 meaning is why `Finish` wakes the parked lease: releasing a slot is work somebody can
539 pick up, and without the wake-up a runner parked on a cap would sit out its whole wait
540 against a queue it could already have. The runners page says `4 of 4 (at the cap)`
541 beside the depth, because a queue that is full and not draining is exactly the question
542 that page exists to answer.
543 (Measured: `TestPerRepoCapBoundsOneRepositoryAndResumesWhenASlotFrees` — two jobs in
544 each of two repositories, cap 1, so "nothing to lease" cannot be confused with "no
545 work": both repositories served once, a third runner refused, a finish wakes the
546 parked lease and the freed slot's own repository is leasable; and
547 `TestRunnersPageSaysWhenARepositoryIsAtItsCap`.)
548
549 ✅ **Slice 11 — a runner can narrow what it takes.** `vole runner --repos name`
550 (repeatable) restricts a session to the repositories it names, and the restriction is
551 applied by the *server* as an intersection with what the account's runner allowlist
552 grants. That direction is the whole design: the flag is a filter, not a grant, so the
553 worst a client can do is run less than it was allowed to. An empty filter means "no
554 narrowing" rather than "nothing", which is what keeps an unfiltered runner following
555 allowlist edits instead of freezing its list at connect time.
556
557 The filter also has a failure mode with no symptom — a runner whose `--repos` matches
558 nothing connects, waits, and never sees work, which looks exactly like a quiet instance
559 — so `ping` now reports the account's grant and the runner refuses at startup, naming
560 the names it asked for and the ones it was allowed. That is the same class of check as
561 the signing key: cheaper to find before a build than after one that never happened. An
562 account whose grant is empty *without* a filter is only a warning, because an allowlist
563 somebody has not written yet takes effect without a restart.
564
565 `runs.Store.Lease` took a `LeaseRequest` struct as part of this. It gained a second list
566 of names, and two adjacent `[]string` arguments are precisely the shape that gets
567 transposed in silence.
568 (Measured: `TestARequestCannotLeaseBeyondTheAllowlist` — a repository the account may
569 *write* to but is not a runner for is refused even when asked for by name, and an admin
570 can still lease it; `TestARepoFilterTakesOnlyWhatItNames`, `TestNarrowCanOnlySubtract`,
571 `TestRepoFilterThatCanNeverMatchIsRefused`,
572 `TestARepoFilterOutsideTheGrantIsRefusedAtStartup`.)
573
574 ✅ **Slice 12 — a job can name the machine it needs.** `runs-on: linux` or
575 `runs-on: [linux, arm64]` on a job, `--label linux` on a runner, and the lease hands a
576 job only to a runner carrying *every* label it asked for. Extra labels on the runner are
577 not a mismatch: the rule is set inclusion, so a machine that is more specific than the
578 job needs still qualifies. A job with no `runs-on` matches every runner, which is what
579 every job did before this existed — so nothing that already runs changed behaviour.
580
581 Both spellings are accepted because both are the obvious way to write it, and a dialect
582 that is already separate from GitHub's should not also fail on the spelling a person
583 guessed. Labels are lowercase and validated in `pre-receive` for the same reason the
584 steps are: a label no runner can ever carry — `Linux`, a typo, a stray space — leaves the
585 job queued forever and looks exactly like a busy pool. The runner refuses a bad `--label`
586 at startup with the same validator, so the two halves cannot disagree about what a label
587 is.
588
589 The labels are **pinned at enqueue** next to the steps and the artifact patterns. A
590 workflow edited after a push must not change where an already-queued job is allowed to
591 run, which is the same rule as "a workflow that mutates its own definition mid-run still
592 executes the version it was queued with". That also settles the derived-store question:
593 `run_labels` is queue state, not history, because a finished run's requirement is
594 recoverable from the pinned workflow blob — so `vole index rebuild` does not restore
595 labels, and nothing needs it to.
596
597 A label is a **claim by the runner about itself**, unlike `--repos`, which the server
598 enforces. That asymmetry is deliberate: a runner that lies about its architecture only
599 takes work it will do badly, which is its own problem, whereas a runner that could name
600 repositories it was not granted would be an access-control hole. `/runners` now shows
601 what each connected runner takes, and says `any job` for an unlabelled one — the other
602 half of "why has my build not started", since the depth alone cannot say that no runner
603 carrying the needed label is connected.
604 (Measured: `TestRunsOnWaitsForARunnerThatCarriesTheLabel` — a runner carrying only
605 `linux` leaves a `[linux, fast]` job queued, and the runner carrying both builds it;
606 `TestLabelsDecideWhichRunnerMayTakeAJob` (eight cases: no requirement, exact match, one
607 label missing, wrong labels, extra labels), `TestLabelsArePinnedAtEnqueue`,
608 `TestForgettingARepositoryDropsItsLabels`, `TestABadRunnerLabelIsRefusedBeforeConnecting`,
609 `TestRunsOnLabelsAreParsedAndValidated`, `TestRunnersPageShowsWhatEachRunnerTakes`.)
610
611 **Exit criteria:** a push runs a pipeline end-to-end ✅; killing a runner mid-job results in
612 the job being requeued and completing on another runner ✅; a workflow that mutates its
613 own definition mid-run still executes the version it was queued with ✅ (steps are pinned
614 at enqueue); a run record can be verified against the SHA it claims ✅ (the runner signs
615 the commit, `git verify-commit` accepts it under the roster afterwards, and the record
616 is on the branch a plain clone carries, so the check needs no access to the instance);
617 a push whose spool file was never written still runs ✅ (the refs are reconciled, measured
618 by `TestReconcileRebuildsAQueueThatWasDeleted`, which deletes the queue and its WAL and
619 gets the live tip queued again).
620
621 ### Phase 5 — LFS — ✅ complete
622
623 ✅ **Slice 1 — the store, and reads through the proxy.** Objects live at
624 `data/lfs/objects/<aa>/<bb>/<sha256>`, shared by every repository on the instance, and
625 are written through a temp file that is renamed only once the sha256 *and* the declared
626 size match, so nothing is ever filed under a key that lies about it.
627 `POST /git/<repo>.git/info/lfs/objects/batch` answers download requests with a plain
628 href, and `GET …/objects/<oid>` serves the bytes with the object id as its `ETag`. The
629 read rule is the clone rule: anonymous for a public repository, `404` for a private one.
630 `operation: upload` over HTTP is refused with a message naming SSH, rather than
631 half-working.
632
633 ✅ **Slice 2 — uploads over SSH.** When the remote is an SSH URL, git-lfs sends objects
634 over the same connection family as the push: the client runs
635 `git-lfs-transfer <repo> upload|download` and Vole answers the pure SSH protocol
636 (pkt-line: `version`, `batch`, `put-object`, `get-object`, `verify-object`, `quit`). No
637 token exists in the exchange — the key at the handshake is the identity, write access
638 comes from the repository's ACL, and bytes are hashed while they stream, so a client
639 that sends the wrong content gets an error instead of an object.
640
641 ✅ **Slice 3 — pointer hygiene at the gate.** `pre-receive` reads the blobs of the tree
642 the pushed ref will serve. A pointer whose object is missing fails the push, naming the
643 object id and the `git lfs push` command that would fix it; a pointer whose declared
644 size disagrees with the stored object fails too, because that checkout would be
645 truncated or stalled. A binary over `lfs_warn_bytes` (default 5 MiB) that is *not* a
646 pointer is accepted with a warning naming `git lfs track "*.ext"` and
647 `git lfs migrate`. The checks run before the protected-branch gate: an admin's approval
648 bypass is about review, not about whether the repository works. Deleting a broken
649 pointer remains possible, or there would be no way out of the mistake.
650
651 ✅ **Slice 4 — CI logs on the same store, and a sweep.** A finished attempt's log is a
652 content-addressed object the **runner** uploaded and named (see the decision log:
653 logs are not streamed), stored per attempt, served as `text/plain` at
654 `/r/<repo>/runs/<id>/log?attempt=N`, and linked from the repository page.
655 `vole gc [--dry-run] [--grace]` marks the object ids named by pointers in every tree
656 reachable from every ref in every repository — plus archived logs, which no ref reaches
657 — and removes the rest. Anything younger than the grace period (default 1h) is kept,
658 because an upload that has not been pushed yet is not garbage.
659
660 ✅ **Slice 5 — file locking.** `git lfs lock <path>` works over the SSH transfer, so a
661 binary that git cannot merge can be reserved by one person before they edit it: a push
662 that changes a locked path is refused, naming the holder, and the lock is released when
663 its holder says so. This is what makes a repository of design files usable by more than
664 one person, and it is the reason a studio would pick a forge with it over one without.
665
666 The HTTP locking API is the usual path for git-lfs, and it is a *write* surface — which
667 this instance does not have, because its web is anonymous and read-only. So locking rides
668 the SSH transfer protocol instead (`lock`, `list-lock`, `unlock`, advertised as the
669 `locking` capability), which means the key that pushed is the key that locks: no second
670 endpoint, no token, nothing new to authenticate. Locks live in `data/users.db` beside the
671 roster and the access rules — the file that holds what git cannot rebuild — because a lock
672 resurrected from history would be exactly wrong: the point of a lock is that it is
673 released. They are keyed by account id, so a rename changes nothing.
674
675 The interesting decision is *where the refusal lives*. git-lfs enforces locks on the
676 client: it asks the server what is held and declines to send the push. That is a courtesy,
677 and a plain `git push` walks straight past it — so the rule also sits in `pre-receive`, at
678 the one place this forge enforces everything else, where it cannot be turned off by a
679 client setting. What it guards is narrow on purpose: only the paths the push actually
680 writes, so reading, branching and merging untouched history are all unaffected. An admin
681 may override, audited, for the same reason a force-push is allowed: otherwise a lock whose
682 holder has left makes a path unpushable forever.
683
684 `/r/<repo>/locks` answers "who is holding what", which the protocol otherwise hides
685 completely — the next person to push is refused with no way to find out why without asking
686 somebody.
687 (Measured: `TestALockRefusesAPushUntilItIsReleased` drives it through the binary over SSH —
688 lock, refused push naming the holder, unlock, the same push landing; `TestLocksAre-
689 AdvertisedOnlyWhenOffered` pins that the capability follows the lock table, since a client
690 that reads `locking` will offer a command that must work; the store's own tests cover path
691 normalization (one file, one lock, however it was typed), paging, owner partitioning and
692 the audited override.)
693
694 **Known gap, stated rather than assumed:** git-lfs is not installed in this environment, so
695 the protocol was implemented against `docs/proposals/ssh_adapter.md` and verified with a
696 client written to that document, not with a shipped client. The framing, the 201/409
697 argument blocks and the five-line lock-spec records are the spec's; whether a real
698 `git lfs lock` agrees is the first thing to check on a machine that has one.
699
700 ✅ **Slice 6 — objects are scoped to the repository that owns them.** The store was
701 instance-wide and content-addressed, so an object was fetchable through *any* repository
702 whose read rule you passed, provided you knew its sha256. That is now impossible, and the
703 fix is structural rather than a check: the pool still holds the bytes once, and each
704 repository has a hard link into a namespace of its own (`data/lfs/repos/<repo>/…`). A fetch
705 reads only that namespace, so "which repository owns these bytes" stops being a database
706 question that can go stale and becomes a fact about the filesystem — and `gc` reconciles
707 the links from the refs, which keeps the project's promise that derived state can always
708 be thrown away and rebuilt.
709
710 The alternatives were worse. A `(repo, oid)` table would have made the read path depend on
711 the index, so deleting `data/index.db` — which the roadmap promises loses nothing — would
712 have stopped LFS fetches until a rebuild. Namespacing the storage itself would have cost
713 the deduplication the store exists for. Hard links give both: one copy of the bytes, one
714 directory entry per repository, and removing one repository's link leaves the other's data
715 untouched.
716
717 Three consequences that had to be handled, not assumed:
718
719 - **`pre-receive` checks pointers against the pushing repository**, not against the pool. A
720 pointer to an object that exists only in another repository would otherwise pass the push
721 and then 404 on clone. The client re-uploads, the pool deduplicates, and the link lands —
722 which is exactly the transfer cost isolation implies.
723 - **The sweep walks per repository**, because reachability is per repository: an object
724 reachable in one and not another keeps the first link and loses the second, while the
725 bytes stay. `vole gc` is therefore also the repair — it links what a repository reaches
726 and unlinks what it no longer does.
727 - **An instance upgrading from the shared pool has no namespaces at all.** Every fetch
728 would 404 until somebody read an upgrade note. So `server.New` notices a store with no
729 namespace and runs the link pass in the background once, using `gc.Link` — the half of gc
730 that creates links and deletes nothing, so an interrupted pass leaves the store more
731 reachable than it found it and the next boot finishes the job. A repository rename moves
732 its namespace with it.
733
734 Reference counting stays rejected. A counter that is one too low deletes somebody's
735 artifact after a crash; a walk over the refs cannot be wrong about what is reachable, only
736 slow — and `gc` is an operator command, not a hot path. The link pass makes that argument
737 stronger: the thing that repairs the store is the same walk that cleans it, and it cannot
738 invent a reference that does not exist.
739
740 **Exit criteria:** a clone of an LFS repository pulls real objects through the proxy
741 (measured: a 256 KiB artifact pushed over SSH and pulled back byte-identical via
742 `http://…/git/<repo>.git`, with the sha256 checked on both ends); a pointer pushed
743 without its object is rejected at hook time (measured with `git push --no-verify`, so
744 the client's own pre-push check cannot stand in for the server's rule); GC never
745 collects an object reachable from a live ref (tested against an object reachable only
746 through history, and against an archived CI log).
747
748 ### Phase 6 — Site hosting — ✅ complete
749
750 ✅ **Slice 1 — the tree is the site.** A repository's `pages` branch is served
751 directly out of git at `<repo>.<pages_domain>`: no export step, no build to drift.
752 Blobs come from a pool of `git cat-file --batch` processes (default 4, per active
753 repository, closed after two idle minutes) and results are cached by `(commit, path)`
754 in an LRU. `ETag` is the blob sha, `Last-Modified` the commit time, `Content-Type`
755 comes from the extension or a content sniff, and conditional requests answer `304`.
756 A directory serves its `index.html`, and a directory URL without a trailing slash
757 redirects so relative links keep working. Path resolution walks trees in memory, so a
758 traversal-shaped URL is simply a missing page — it never reaches the filesystem.
759 Sites follow clone visibility: a private repository has no site, because there is no
760 session with which an anonymous reader could authenticate. The ref tip *and* the
761 authorization decision are resolved together and memoised for one second, and a moved
762 tip drops the previous commit's cache entries, so a push is live well inside the
763 five-second criterion. (Measured on this implementation: 6.7 µs per warm request,
764 144 µs with the caches cleared — the difference between a docs site that feels like a
765 file server and one that feels like a database. Resolving the repository and its ACL
766 per request cost 3.8 ms, because it spawned three `git` subprocesses; that is why both
767 are resolved once per memo window.)
768
769 Decisions:
770
771 - **Per-repository custom domains and ACME are dropped, not deferred.** An instance
772 already owns one domain and terminates TLS in front of it; sites inherit both from
773 the forge. Asking Vole to request certificates would add an ACME client, a
774 challenge listener and a DNS story to solve a problem the operator solved once
775 already. `pages_domain` is the single setting: point it at the forge's own domain
776 and every repository's site is a subdomain of it.
777 - The proxy must preserve the `Host` header (`proxy_set_header Host $host` in nginx,
778 the default in Caddy). `Host` is how a request names its site; nothing else routes.
779 - A per-repository pages ref is not planned. One ref name for the instance keeps the
780 mental model to one line, and a repository that wants a different branch can say so
781 by pushing to `pages`.
782 - The site branch is a normal branch with no review story of its own: protecting it
783 is `vole acl protect`, and a site change is reviewed exactly like code.
784
785 **Exit criteria:** a pushed doc tree is live in under 5 seconds ✅ (measured: pushed
786 over SSH, served on the site host, polled until the new bytes appeared, 1.4 s);
787 conditional requests return `304` ✅; serving a repo with a path-traversal-shaped
788 filename is safe ✅ (and so is a traversal-shaped *request*). The former criterion
789 "a custom domain gets a certificate with no manual steps" is withdrawn: certificates
790 are terminated in front of the instance, and a site is a subdomain of the domain that
791 certificate already covers.
792
793 ### Phase 7 — The forge experience
794
795 The system does the hard parts; this phase is about the ten minutes somebody spends
796 using it. Read-only web work first, because review is where a forge is judged and the
797 web is already the review surface.
798
799 ✅ **Slice 1 — review on the web.** `GET /r/<repo>/commit/<sha>` shows a commit's
800 metadata and the patch it introduced, with per-file hunks, line numbers and
801 added/removed rows; the commit list links to each one. A change request shows the same
802 diff, computed three-dot so a review is "what this branch adds since it diverged"
803 rather than every commit `main` gained meanwhile. Diffs are capped at 512 KiB and say
804 when they are cut, binary files are named rather than faked, and every line is escaped
805 — a diff is other people's content, and a test proves a `<script>` line does not
806 execute. A repository's README renders on its home page, and markdown blobs render as
807 markdown, through the same sanitiser issues use.
808
809 ✅ **Slice 2 — releases are tags, on the web.** `core/git/tags.go` lists
810 `refs/tags` peeled to the commits they name, and `server/web/releases.go` renders
811 them: `/r/<repo>/releases` lists every tag and labels the lightweight ones, and
812 `/r/<repo>/releases/<tag>` shows the tag message as notes (through the same markdown
813 sanitiser as every other pushed body), the commits since the previous release, and two
814 downloads. Tarballs are generated per request by `git archive` and never stored, so a
815 tarball cannot disagree with its tag; the `ETag` is the tag object id plus the format,
816 and a conditional request gets a `304`. `vole release create <tag> -m|-F` writes the
817 annotated tag and refuses the two mistakes that produce a release page with nothing on
818 it: an empty message and an existing tag. "Since the previous release" is
819 `git describe --abbrev=0 <tag>^` rather than the next row in the tag list, because two
820 tags created in the same second sort arbitrarily and a page that changes its baseline
821 between requests is worse than one with no baseline. Compressed archives queue behind a
822 semaphore of 4; plain `tar` does not, because it streamed at 5,005 MiB/s under 8
823 concurrent requests. `git archive` copies LFS pointers verbatim, so the page warns when
824 the tagged tree tracks LFS, which is the honest alternative to resolving pointers while
825 streaming.
826 (Measured on an 11.6 MiB, 2,566-file repository served by the binary: release list
827 6.1 ms, release page 12.6 ms, `.tar` 53.7 ms / 13.7 MiB, `.tar.gz` 218 ms / 1.8 MiB,
828 conditional request `304`; every file in the served tarball hashed to its `ls-tree` blob
829 id, 2,565 of 2,565.)
830
831 ✅ **Slice 3 — artifacts.** `vole release attach <tag> <file>...` speaks the pure-SSH
832 LFS transfer protocol itself (`ssh host git-lfs-transfer <repo> upload`: version, batch,
833 put-object, verify-object), so bytes go in over the key that already writes and there is
834 no token, no second endpoint and no new write surface. It then commits a pointer for each
835 file on `refs/heads/vole` — bytes first, ref second, so a failed upload publishes
836 nothing, and the server refuses the reverse order because a pointer whose object is
837 missing fails `pre-receive`. The artifact list *is* that directory: `releases/<tag>/<name>`
838 holds a standard pointer, which the sweep already marks (`gc` parses pointers in every
839 tree reachable from every ref) and the hook already validates, so artifacts arrived with
840 no GC change and no new format. Downloads are
841 `/r/<repo>/releases/<tag>/artifacts/<name>`, served out of the object store with the
842 object id as `ETag`, `Content-Disposition: attachment` and `nosniff` always, `Range` for a
843 resumable 100 MiB download, and the clone read rule so a private repository's artifacts
844 are `404`. Re-attaching identical bytes uploads nothing, because the store is
845 content-addressed.
846 (Measured end to end through the binary: two 2.9 MiB files uploaded over SSH in 390 ms,
847 the second deduplicating to one object and no transfer; both downloaded in 13.9 ms each
848 with a matching `sha256`, `304` on `If-None-Match`, `206` on `Range: bytes=0-99`;
849 `vole gc --dry-run` reports the published artifact reachable and removes nothing.)
850
851 ✅ **Slice 4 — published releases are immutable.** `pre-receive` refuses to move or
852 delete a tag that `releases/<tag>/` has artifacts for, and on the metadata branch it
853 refuses removing a pointer or re-pointing one at different bytes (the blob id is the
854 pointer text, so "unchanged" needs no parsing). Additions are allowed, because an
855 artifact is published when its pointer lands and an addition invalidates nothing. The
856 rules sit with the integrity checks rather than the review gate — a download that
857 changes bytes is broken whoever pushed it — but an admin may override, with a line in
858 `data/audit.log`, because a mistyped tag is otherwise unrepairable. Four directions are
859 tested against a writer account: moved tag, deleted tag, removed pointer, re-pointed
860 pointer; and the audit line for the admin override.
861
862 ✅ **Slice 5 — pages from a build directory.** `vole pages deploy [<dir>]` publishes
863 a directory as the site branch: the branch's tree becomes exactly those files, so a
864 page deleted from the build stops being served instead of living on until someone
865 notices. It is a normal commit on top of the tip (no force, no history rewrite), so a
866 protected `pages` branch still goes through review, and re-applying after a lost push
867 race is re-running the snapshot rather than merging two builds. `vole pages status`
868 reports the tip, the commit, the file count and the bytes; `vole pages unpublish`
869 deletes the branch, which is what makes the site 404. `--dry-run` prints the
870 added/changed/removed counts, and a deploy whose files match the published tree pushes
871 nothing — a build that reruns on every commit must not add an empty commit per run.
872 (Written as it was used: the local branch and the server's ref are two different
873 things. Both `status` and `deploy` ask the server with `git ls-remote` rather than
874 reading the clone, because a local branch outlives an unpublish — the first version
875 reported "already published" for a site that was 404ing, and a test caught it.)
876 (Measured through the binary against a real instance with `pages_domain` set: 3 files
877 deployed and served at `demo.pages.test` within the one-second window; a deleted file
878 404d after the next deploy; re-deploy after unpublish took 325 ms; `vole pages status`
879 reported `3 file(s), 19 B`; unpublish twice is a no-op.)
880
881 ✅ **Slice 6 — run artifacts.** A job declares outputs rather than shelling out to a
882 CLI: `artifacts: [{path: dist/*}]`, validated at push (relative, no `..`, no `**`, a
883 glob that parses), so a typo fails the push that introduces it instead of a job hours
884 later. The patterns are pinned into the queued run next to the pinned steps, expanded
885 by the runner in its checkout after the steps pass, and uploaded over SSH through the
886 same object transfer git-lfs uses — the runner holds no other credential, and the
887 store is the authority on whether the bytes exist, so a claim it cannot serve is
888 refused. The list rides the **finish** message rather than a separate call: a run's
889 outputs and its result are one fact, so there is no window in which a success has no
890 artifacts or a failure has some. Run artifacts live in the object store and are named
891 by a `run_artifacts` table, **not** on the metadata branch: a build output is
892 machine-written state, and the run queue is the only index it should have. They are
893 downloadable at `/r/<repo>/runs/<id>/artifacts/<name>` with the object id as `ETag`,
894 `Content-Disposition: attachment`, `nosniff` and `Range` — the same rules as release
895 artifacts, because it is the same kind of byte — and `vole gc` marks them from the
896 queue the way it marks archived logs. A pattern that matches nothing fails the job,
897 and two files with one base name are refused, because a silent empty artifact is the
898 failure people find a week later.
899 (Measured through the binary against a real instance: a two-file `dist/*` declaration
900 ran on a real runner, uploaded both over SSH, and the run page served them with a
901 matching `sha256`; `vole gc --dry-run` reported 3 objects and 3 reachable roots — the
902 log and both artifacts — removing nothing.)
903
904 ✅ **Slice 7 — a site built by CI.** `pages: {path: site}` on a job publishes that
905 directory as the site branch, through the *same* snapshot code `vole pages deploy` uses
906 (`deploySite` in `cmd/vole/pages.go`), so a site published by a workflow and one
907 published by a person are the same commit shape. The directory is pinned into the run
908 at enqueue like the steps and the artifact patterns; the branch name comes from the
909 server (`pages_ref`) in the job payload, because instance configuration is not
910 something a workflow can know. The commit message names the build
911 (`site: main from run <id> (<sha>)`), so `git log pages` explains the tree. Two guards
912 earned their place: one job per workflow may declare `pages` (two would race, and the
913 winner would be whichever runner finished last), and a deploy is refused when the run's
914 SHA is no longer the tip of its branch — a snapshot deploy from a stale build silently
915 reverts the site, which is worse than a failed job. The refusal is a failed run whose
916 log says `this run built <a> but main is at <b> now; the site was not deployed`.
917 (Measured through the binary: two pushes queued two runs, the older one refused with
918 that message and published nothing, the newer one deployed and the site host served the
919 new tree; the commit on the pages branch read
920 `site: main from run 01M5MSC3... (6beaa530)`. Also fixed on the way: a URL handed to
921 the shared push helper was mistaken for a missing *named* remote, so a runner's deploy
922 committed locally and reported success — the end-to-end test caught it, because the
923 site served a 404 after a "successful" run.)
924
925 ✅ **Slice 8 — a tag push builds the release.** `on.push.tags` opens CI to tag pushes,
926 and `release: true` on an artifact publishes it to *that tag's release* rather than only
927 to the run. The runner does exactly what `vole release attach` does — commits the same
928 pointers on `refs/heads/vole` — so the release page, the ETags, the immutability rules
929 and the sweep needed no changes at all. Three rules earned their place. A workflow that
930 says `tags:` runs only on tags: naming your trigger is naming it, and the alternative
931 queued a release build on every commit (measured: a branch push to a tag-only workflow
932 queued zero runs). A tag run never counts as a branch's required check, even when the
933 tag points at that branch's tip — `ForSHA` now filters to `refs/heads/%`, or a release
934 build could satisfy or break the gate for the commit it tags. A lightweight tag cannot
935 name a release: the build runs and the publish fails saying so, because a tag with no
936 message has no notes and no tagger.
937 (Measured through the binary: a branch push queued nothing, a tag push queued the
938 release job, the runner published `dist/tool-linux`, the release page listed it with the
939 tag marked annotated, the download returned the bytes, and `git log vole` read
940 `release: v1.0.0 artifacts from run 01M5MSX3...`.)
941
942 Planned:
943
944 - ✅ **Identity without ceremony.** `vole whoami` asks the instance which account
945 the key resolves to (`vole-whoami` over the same SSH channel), and `vole issue`
946 and `vole request` stamp that account's name on what they write. `--author` is
947 gone from both: a name you can type is a name you can get wrong, and the old
948 default — `git config user.name`, sanitised — turned "Keenan Nemetz" into
949 `keenan-nemetz`, an account that does not exist, silently. The fallback when the
950 instance cannot be reached is the git-config guess **with a warning**, because a
951 `--no-push` run on a train still has to commit something. `vole request approve
952 --as <user>` still exists; it is the one remaining place a name can be typed, and
953 the hook refuses a forged approval anyway.
954 - ✅ **Filing an issue or opening a request without a clone** (`--remote ssh://…`).
955 It worked once per repository and then failed, because `fetch` skipped a URL:
956 the push came from a tip that had never been updated and the retry reported a
957 "lost race" that had not happened. A URL is now fetched like a named remote, so
958 `git init` in an empty directory plus a URL is a working filing path (measured by
959 two repositories that never clone, both landing).
960 - ✅ **Empty states print the command that fills them**, and `new`/`comment` print
961 the page they wrote when `public_url` is set. Repo tabs with counts and
962 breadcrumbs everywhere are still open.
963 - Per-file history (`/commits/<ref>/<path>`), so "when did this change" is one click.
964
965 Releases: decisions and what is left. Design settled by
966 [spike D](spikes/archive-streaming/README.md):
967
968 - **There is no record file.** The tag holds the notes and the tree; the pointer
969 directory holds the artifact list; the sweep and the hook already read both. A
970 `releases/<tag>.json` was planned to hold a draft flag and an artifact list, and it
971 earned neither: an artifact's state *is* its pointer, and immutability is a diff of the
972 pointer tree, so the file would have carried a flag duplicating what the ref already
973 says. **Drafts are dropped with it** — a release becomes visible when its tag does, and
974 attaching artifacts to a tag nobody has announced yet is the draft.
975 - **An artifact is a literal LFS pointer blob** at `releases/<tag>/<name>`. Measured:
976 `forge.GC` marks only blobs that `lfs.ParsePointer` accepts (`forge/gc.go:84`)
977 and walks them from `rev-list --objects --all` (`core/git/git.go:705`), which
978 includes `refs/heads/vole` — so published artifacts are reachable with **no GC
979 change** — and `pre-receive` pointer hygiene already checks the oid and the size at
980 the boundary. A JSON array of sha256 strings would parse as no pointer, count as
981 unreachable, and be swept once the grace period passed.
982 - **Source archives are generated per request, never stored.** Spike D: a real
983 11.6 MiB repository archives in 54 ms plain and 221 ms gzipped, byte-identical
984 across runs and from a fresh clone, first byte at 4 ms. `ETag` is the tag object id
985 plus the format, so even a cache could not serve a stale byte — and none is needed.
986 Plain `tar` is the default: the 100 MiB binary fixture gzips 119.2 MiB to 80.4 MiB
987 for 13× the CPU, and `core.compression` is inert (levels 1, 6 and 9 produce
988 identical bytes). Only compressed requests need a semaphore; 8 concurrent plain tar
989 streams ran at 5,005 MiB/s against 273 MiB/s gzipped.
990 - **A source archive of an LFS repository contains pointers, not bytes** (measured), and
991 the page warns when the tagged tree tracks LFS. Resolving pointers from the store while
992 streaming is the change that would make the tarball complete; the trigger is the first
993 repository where a tarball of pointers is not good enough.
994
995 Remaining:
996
997 - **Per-artifact size cap.** Artifacts ride `lfs_object_max_bytes` today, which is the
998 same cap as an LFS object; a release of build outputs may want its own, and that is a
999 config key to add when someone hits it, not before.
1000 - **Signed artifacts.** Deferred with the rest of signing (see the signing block): the
1001 only verifier available is the instance whose bytes are in question.
1002
1003 **Exit criteria:** a reviewer can approve a change using only the web — diff, checks and
1004 approvals on one page — without cloning; a new user can go from `vole init` to a pushed
1005 commit following the README alone; every page has a working path to its neighbours; a
1006 tagged release with an artifact attached over SSH downloads anonymously and matches
1007 `sha256sum` locally, and returns `404` on a private repository ✅ (measured: 2.9 MiB
1008 attached over SSH and downloaded in 13.9 ms with a matching sha256; the size in the
1009 criterion is unmeasured — the fixture was 2.9 MiB, and `lfs_object_max_bytes` is what
1010 bounds it; proven by `TestReleaseArtifactDownload` and
1011 `TestReleaseArtifactsArePrivateForPrivateRepos`). A published artifact cannot be detached
1012 or re-pointed ✅ (slice 4; proven by `TestPublishedReleaseIsImmutable`, which drives all
1013 four refusals through a writer account).
1014
1015 ### The trust model
1016
1017 **One model, stated once.** Identity is established cryptographically at the SSH handshake
1018 and resolved to an account by the roster. Every decision about that account is made at one
1019 boundary — `pre-receive` — and every decision is written to one append-only log,
1020 `data/audit.log`. Nothing else in the system claims to prove anything.
1021
1022 That is the whole of it, and it exists as a section because it used to be two answers. A
1023 runner's result was attributed by a commit signature; an issue's author, a comment's and an
1024 approval's were attributed by the account whose key pushed. One path was cryptographic and
1025 lived in the object, three were boundary-based and lived in the instance's word. Keeping
1026 both meant keeping `allowed_signers` generation, `ssh-keygen -Y find-principals` semantics,
1027 a `--key` contract in the runner, a key column in the queue, a pre-flight key check before
1028 every build, and the standing obligation to keep all of that agreeing with the other three.
1029
1030 **The signature was removed, and the reasoning is worth keeping** because it will be
1031 proposed again. It lost on three points:
1032
1033 - *It restated the handshake.* The runner signs with the key it already pushes with, so a
1034 reader learns nothing the SSH channel had not already established — except portability,
1035 which is a property nobody in this deployment consumes.
1036 - *It proved attribution, not truth.* A runner could sign a lie. The roadmap already said
1037 so; what it did not say is that a mechanism which nobody verifies is a liability, because
1038 it looks like assurance.
1039 - *It was one path where every other fact had another.* An irregularity in a system whose
1040 whole design is "one enforcement point" is not a feature.
1041
1042 **What replaced it: the push log.** Attribution is the boundary's, so what the instance owes
1043 a reader is a record of what it decided. `post-receive` writes one line per accepted ref
1044 update — `push <ref> <old> -> <new> by <name> (<account id>) in <repo>` — into the same log
1045 as the privileged acts (force-pushes, lock overrides, release rewrites, merges through
1046 review, roster and ACL changes). It is written in `post-receive` rather than `pre-receive`
1047 because that is the point at which the refs have actually moved: pre-receive returning 0 is
1048 a permission, not an event. It cannot fail a push.
1049
1050 Measured, so the next person does not have to redo it (git 2.53, OpenSSH 10.2): `git
1051 commit -S` and `git commit-tree -S` sign with `gpg.format=ssh`, so the roster's keys could
1052 sign; `git verify-commit` against a roster-derived `allowed_signers` accepts any key in the
1053 file and does **not** tie the signature to the committer email, so identity comes from
1054 `ssh-keygen -Y find-principals` and not from anything the commit says; and a signature
1055 covers a commit rather than a file, which is why the rule would have had to be "the commit
1056 that last touched this path is signed by the account it names" — workable, and exactly what
1057 the run records did.
1058
1059 **The cost, stated plainly:** a clone cannot show who pushed what. A reader of a bare
1060 repository has to trust that the instance enforced its rules, and that its log is honest.
1061 That is the trade, and it is the wrong trade for one kind of deployment — an instance
1062 mirrored somewhere its operator does not control, or a third party who must verify without
1063 trusting the box. If that arrives, the answer is mandatory signing *for the repositories
1064 that need it* (`vole acl signing`), implemented once, with a `vole verify` that replays the
1065 rules from a clone — not a second mechanism kept warm in the meantime.
1066
1067 ### Phase 8 — Operate it
1068
1069 - Metrics and traces (job queue depth, CAS retry rate, push latency, index lag)
1070 - Backup and restore: documented artifact set, plus a restore drill in CI
1071 - Quotas: per-repo size, object count, LFS bytes
1072 - Metadata GC that never breaks history: entities are tombstoned, not dropped
1073 - Upgrade path with an explicit schema-version step and a downgrade refusal that explains itself
1074 - Multi-arch container images and a `vole init` flow that produces a working instance in
1075 one command
1076 - Operator docs: TLS, SMTP, runner registration, backup cadence
1077
1078 **Exit criteria:** restore-from-backup drill passes in CI; upgrade N→N+1 succeeds on a repo
1079 with live LFS objects and completes with no downtime; `vole init` produces a working
1080 instance with one command on a clean box.
1081
1082 ## Risks
1083
1084 **Metadata writes: measured, resolved.** Spike A closed this. Sharded + batched refs sustain
1085 6,966/s at 2k entities and 25,637/s at 20k with zero lost writes; the unsharded alternative
1086 is 30/s and decays quadratically. The decision is made and Phase 2 just has to implement it
1087 correctly. The remaining unknown is the **derived-index cost**, which spike A deliberately
1088 excluded — hence the ≥500 writes/s-at-100k criterion in Phase 2.
1089
1090 **Push memory is bounded by client behaviour, not by us.** A single 1 GiB push peaked at
1091 1.13 GB of git-child RSS. Three concurrent 1 GiB pushes would OOM a 4 GB box. Mitigation:
1092 size cap plus concurrency semaphore in Phase 1, both verified by test, not by configuration
1093 review.
1094
1095 **Chunked uploads consume temp disk per concurrent push.** git's default client config sends
1096 chunked requests, which must be spooled to learn `CONTENT_LENGTH`. Observed 48.9 MiB for a
1097 1 GiB push, but the quantity scales with what the client sends and with concurrency.
1098 Mitigation: spool size cap, cleanup on error and abort, and a startup janitor for files
1099 orphaned by a hard crash.
1100
1101 **Derived index can silently drift from refs.** Any path that writes refs without updating
1102 the index is a bug that shows up as missing data. Mitigation: the index is never written
1103 except through one function that is given the ref transaction it is updating; a scheduled
1104 `rebuild` diff against incremental state runs in CI.
1105
1106 **Hook-enforced policy can be bypassed by server-side operations.** Merges and other
1107 server-initiated ref updates must route through the same validation as `pre-receive`, or
1108 the ACL model has two implementations that will disagree. Mitigation: one policy function,
1109 called from both, with a shared test fixture table.
1110
1111 **Large diffs remain unmeasured.** Spike C covered static file serving, not three-dot diffs
1112 on a CR with thousands of changed files. Phase 3 should measure that before the diff viewer
1113 is designed around an assumption.
1114
1115 ## Decision log
1116
1117 Filled in as phases land. Append; don't rewrite.
1118
1119 | Date | Decision | Rationale |
1120 |---|---|---|
1121 | 2026-10-22 | Single-tenant, no federation | Keeps scope finite; removes global IDs, peer protocol, and multi-writer storage |
1122 | 2026-10-22 | Git authoritative, SQLite derived | Rebuildability makes the storage choice reversible |
1123 | 2026-10-22 | Metadata refs sharded 256 ways, writes batched | Spike A: 30/s unsharded (and decaying) vs 6,966/s at 2k and 25,637/s at 20k sharded+batched |
1124 | 2026-10-22 | CAS ref updates as the only write primitive | Spike A: zero lost writes in every configuration, including 32 writers on one ref |
1125 | 2026-10-22 | Smart HTTP via `git http-backend` subprocess | Spike B: hook stderr propagates, aborts leave no ref and no orphans, 150 MiB/s on compressible data |
1126 | 2026-10-22 | Push size cap + concurrency semaphore in Phase 1 | Spike B: 1 GiB push peaked at 1.13 GB git-child RSS |
1127 | 2026-10-22 | Static sites served from trees, no export step | Spike C: cold 1.2 ms p50; 170k req/s warm with an 18.5 MiB cache |
1128 | 2026-10-22 | `pre-receive` is the only policy hook | One enforcement point; all-or-nothing, so a refusal can never half-apply |
1129 | 2026-10-22 | Clients may not push `refs/vole/*` (superseded 2026-10-23: the namespace is unused and the general rule covers it) | The ACL lived there, and allowing it would let a writer self-grant admin. Once the rules moved to `data/users.db` nothing was stored there, so the special case went rather than being kept as a guard: `refs/vole/*` is refused now because it is not a branch or a tag, the same rule that refuses `refs/notes/*` |
1130 | 2026-10-22 | SQLite via `modernc.org/sqlite` (two files: auth, index) | CGO-free single binary; the index file is disposable, auth is not |
1131 | 2026-10-22 | Bad credentials return 401 with a challenge | 403 leaked repo existence and suppressed client retry |
1132 | 2026-10-22 | Writes move to SSH; HTTP becomes anonymous read-only | Removes password storage, login, sessions and the token table outright |
1133 | 2026-10-22 | Web authentication dropped; the web is read-only | Read-only content is exactly what an anonymous principal may see |
1134 | 2026-10-22 | Identity in `system.git`, roster on `refs/heads/main` (superseded 2026-10-23) | Public keys only; a plain clone is a complete, non-leaking backup |
1135 | 2026-10-22 | Roster replaced by a `trusted_keys/` directory (superseded 2026-10-23) | The git-backed version carried unused fields and ~280 lines of CAS/tree machinery; the only real loss was audit, now `data/audit.log` |
1136 | 2026-10-22 | `receive.maxInputSize` replaces the HTTP byte cap | git enforces it on both transports; the proxy never buffers a pack |
1137 | 2026-10-22 | Issues are push-based on `refs/heads/issues`, IDs are client-generated ULIDs | No authenticated web write surface exists, and a branch makes issues travel with a clone |
1138 | 2026-10-22 | Issue trees validated in `pre-receive` | Reject bad data at the boundary instead of rendering it |
1139 | 2026-10-22 | CLI follows gitcli(7): `--long`, `-o`, options first, `--`, exit 129 | A tool that speaks git's dialect composes with git and with muscle memory |
1140 | 2026-10-22 | Issues and requests share `refs/heads/vole`; linear history, no merges | One ref, one push, atomic cross-cutting edits; entities are separate files so a re-apply never conflicts |
1141 | 2026-10-22 | Force-push to metadata is admin-only and audited | Redaction is a real need, but it must never be silent |
1142 | 2026-10-22 | Approvals name a commit and a branch; the merge is a push | A branch-moving head makes "approve once" meaningless, and a push needs no new write surface |
1143 | 2026-10-22 | Only the named principal may write their own approval file | A filename is not authentication; the hook compares it to the SSH identity resolved at handshake |
1144 | 2026-10-22 | Approval ownership checks cover modify/rename, not just add | A file that already exists can be edited to name a different commit; `--diff-filter=A` alone is bypassable |
1145 | 2026-10-22 | Accounts have a stable id; names are mutable and never reissued | A name is not an identity: renaming broke ACLs and orphaned approvals, and reusing a name would hand over someone's access |
1146 | 2026-10-22 | The roster lives in `data/system.git` on `refs/heads/main` (superseded 2026-10-23) | One authoritative copy, portable and auditable with plain git; per-repository copies would make revocation non-atomic |
1147 | 2026-10-22 | `gitx` scrubs git's repository-scoped environment | pre-receive sets `GIT_OBJECT_DIRECTORY`/quarantine for the pushed repo, which silently redirected a hook's git calls for other repositories |
1148 | 2026-10-22 | CLI accepts options after arguments, as git does | Refusing them is a local dialect; `git commit -m x path` is the shape people type |
1149 | 2026-10-22 | Source tarballs are generated per request, never stored | Spike D: 54 ms plain / 221 ms gzipped for a real repo, byte-identical across runs and clones; plain `tar` is the default because gzip saves 32% on binaries for 13× the CPU and its level knob does nothing |
1150 | 2026-10-22 | A release is an annotated tag, not a row in a table | Notes, tree and provenance already live in the tag; a lightweight tag has no tagger and no notes, so the web labels it instead of pretending |
1151 | 2026-10-22 | Release artifacts are LFS pointer blobs on `refs/heads/vole` | The sweep already marks pointers in any tree reachable from any ref and the hook already checks object and size, so artifacts cost no new format, store or GC rule; a JSON manifest of sha256 strings would have been swept as garbage |
1152 | 2026-10-22 | No release record file, and no drafts | The artifact list is the pointer directory and published-ness is the ref itself; a `releases/<tag>.json` would carry a flag that duplicates the tag and an edit history nobody asked for |
1153 | 2026-10-22 | Attach uploads bytes before it commits the pointer | The reverse order publishes a download that 404s; the hook cannot see bytes that were never in the pack, so the pointer check is the only guard and the GC grace period covers the gap |
1154 | 2026-10-22 | Artifact downloads are always an attachment, served through `http.ServeContent` | An uploaded `.html` rendered inline is stored XSS on the forge origin; `ServeContent` gives `Range` and conditional requests for free, and the LFS object id is the validator |
1155 | 2026-10-23 | Saying `on.push.tags` means tags only | A workflow that named its trigger still firing on every branch queued a doomed release build per commit; "no triggers at all" keeps the existing every-branch default |
1156 | 2026-10-23 | A tag run never decides a branch's required checks | A tag on a branch tip produces a second run for the same sha; counting it would let a release build satisfy — or break — the gate for the commit it tags |
1157 | 2026-10-23 | CI release artifacts are the same pointers a person commits | The release page, ETags, immutability and the sweep already read that shape, so CI needed no new publish path — only the run id in the commit message |
1158 | 2026-10-23 | CI deploys the site through the CLI's own snapshot code | A site published by a workflow and one published by a person must be the same commit shape; a second implementation would drift, and the version that drifts is the one nobody runs by hand |
1159 | 2026-10-23 | A CI site deploy is refused when the run is not the branch tip | Deploy is a snapshot, so a stale build finishing late silently reverts the site; a failed job with a reason is the honest outcome |
1160 | 2026-10-23 | One job per workflow may publish the site | Two publishers race and the winner is whichever runner finished last, which is not a decision anyone made |
1161 | 2026-10-23 | Workflow outputs are declared (`artifacts:`), not CLI calls in steps | A workflow is validated at push, so a bad path fails the push; a step calling the CLI fails at run time, needs the binary on the runner's PATH, and gets idempotency wrong under retries |
1162 | 2026-10-23 | Run artifacts are named by the run queue, not by a ref | A build output is machine-written state; the metadata branch is for human-authored entities, and the sweep already takes extra roots from the queue for logs |
1163 | 2026-10-23 | A run's artifact list rides the finish message | Outputs and result are one fact: a separate call leaves a window where a successful run has no artifacts, or a failed one has some |
1164 | 2026-10-23 | `vole pages deploy` publishes a snapshot, not a merge | A site is a build directory: merging leaves deleted pages served forever. Snapshot is a normal commit on the tip, so protection and review still apply |
1165 | 2026-10-23 | Pages commands ask the server (`git ls-remote`), not the clone | A local branch outlives an unpublish, so the clone describes a site that 404s; the deploy said "already published" until it looked at the server |
1166 | 2026-10-23 | The roster is `data/users.db`; clients read it with the `vole-roster` SSH verb | Accounts are rows, not commits: SQLite enforces "a name is issued once" and "a key belongs to one account" as table keys, and a backup is one file. The client still needs an id/name map, so it asks over the key it already pushes with instead of cloning a repository that held nothing else. Supersedes both roster rows above |
1167 | 2026-10-23 | Metadata on `refs/heads/vole` is frontmatter markdown, not JSON | [Spike](spikes/issue-frontmatter/README.md): the same six edits cost 25 changed lines as `meta.json` and 10 as frontmatter. A JSON array rewrites its neighbour to add a comma; one label per line is one added line, and one file per entity means one path to diff, blame and log. Always quoting free text turns the YAML edge cases into a writer concern instead of a title-restriction list, and the validator only has to refuse an unquoted title the parser read differently. The `updated` field was dropped: it was 11 of the 21 frontmatter lines, and the commit already carries the time |
1168 | 2026-10-22 | A published release is immutable, admin-overridable and audited | A link whose bytes can change is worse than no link; but refusing outright leaves a mistyped tag unrepairable, so the override follows the force-push precedent: admin only, always audited |
1169 | 2026-10-23 | An assignee is a name held to the account-name rule, not roster-checked at push | It names a person, so the label rule (which allows dots) would accept a value no account can have — but resolving it against the roster at the gate would fail a push written offline, or for someone added after the assignment. A name in frontmatter is a claim on the same footing as `author`; the web shows it and the filter links by it. Stored as written, so a rename never rewrites history (names are never reissued) and the file stays readable in a clone |
1170 | 2026-10-23 | The index carries a schema version; a bump empties the derived rows | A new table and column would leave existing instances serving rows derived from the old shape until the next push touched each repo. The index is disposable, so the honest migration is "delete and let the next read rebuild from git" — a stale row is worse than a rebuild, and the version stops a half-applied schema from being read as if it were complete |
1171 | 2026-10-23 | An edit that changes nothing is not a commit | `vole issue assign` on an already-assigned person would otherwise add an empty commit to a linear branch, and a script that reruns has to be idempotent without growing the log. `Update` reports `ErrNoChange` and the branch does not move |
1172 | 2026-10-23 | A comment is edited in place, deleted by removing its file, and has no `edited` field | The file is the comment: keeping the path keeps the id, the thread order and one `git log -p -- <path>` that answers "what did it say". A deleted comment leaves its text in history, which is honest — redaction is still an admin force-push — and a flat ULID-keyed thread has no replies to orphan. `edited:` would repeat the argument that dropped `updated`: a line rewritten on every edit, when the commit already carries the time |
1173 | 2026-10-23 | Comment authorship is checked against the old blob, and only for comments | The old blob was validated when it landed; the new one is whatever the pusher wrote, so comparing it would let anyone adopt a comment by editing its author line in the same push. Restricting the rule to comments is deliberate: `state`, `labels` and `assignees` are shared fields and a maintainer who cannot close or assign someone else's issue has no triage. Admins may still repair a thread, audited |
1174 | 2026-10-23 | The author of a metadata write is the account the key resolves to, asked with `vole-whoami`; `--author` is gone | `git config user.name` is a name somebody typed for commits: "Keenan Nemetz" sanitises to `keenan-nemetz`, which is not an account, so every issue filed from a normal machine was attributed to nobody and nothing complained. The instance is the only place that maps a key to an account, and it is one SSH verb away on the connection already open. A fallback exists for `--no-push` with the instance unreachable, and it warns loudly rather than guessing silently |
1175 | 2026-10-23 | A remote given as an SSH URL is fetched like a named one | Skipping the fetch for a URL is what made `--remote ssh://…` write on a branch that had never been updated: the first write in a fresh repository succeeded (nothing to fast-forward from) and every later one failed as a "lost race" that never happened, re-applying on the same stale tip and reporting "already exists". `git init` plus a URL is now the real no-clone filing path |
1176 | 2026-10-23 | The instance tells the client where it is served (`public_url`), so a command can print the page it wrote | A command that answers with an id leaves the reader to assemble a URL, and the instance is the only party that knows its own address behind a proxy. Optional: unset means ids only, which is what it printed before |
1177 | 2026-10-23 | CLI edits to a list are flags, not signs | The first cut of `acl runners` used `+name` to add and `none` to clear, which is a local dialect: git edits lists with flags (`config --add`, `config --unset-all`, `remote set-url --add`) or with subcommands (`sparse-checkout add`). The sigil appeared because `-name` collides with the flag parser, which is a reason to use flags, not a reason to invent syntax. Names replace the list (matching `acl protect`), `--add`/`--remove` edit, `--clear` empties. The refusal message a push prints quotes the same form, so a copy from the terminal is never stale |
1178 | 2026-10-23 | A result counts only if the host allowed the account that reported it | An attributable record still had a hole: any writer who was an admin could green-light their own branch, and an admin of a repository is exactly the party the check is meant to constrain. So the question "whose build do we believe" became its own allowlist — `acl.runners`, with admins implicit because they can rewrite it. It also governs who may lease a job, since running one executes the repository's code. The result from an unlisted account is still recorded and visible; it simply does not count, and the page says so. Deliberately *not* a role: read/write answers "may this account touch the repository", and conflating the two would make every writer trusted about builds |
1179 | 2026-10-23 | The instance holds no signing key at all | The instance key existed to sign run records, and runner signing (Phase 4 slice 4) made it pointless. Deleting it is not tidying: an instance-signed anything is a statement that can only be checked by trusting the instance, so a mirror or a third party cannot verify it — trust would sit on the box. With users, runners and approvers each holding a key, trust sits on the parties, and the server becomes a relay that can withhold service but cannot forge a result. `/healthz` no longer reports a key fingerprint, and the host key is the only secret the instance stores |
1180 | 2026-10-23 | A finished run is signed by the runner that ran it, not by the instance (superseded in part 2026-10-23: the signature is over the commit, not a separate claim) | An instance-key signature is the server witnessing itself: it cannot be checked without trusting the box that produced it, and it says nothing about the party that actually ran the steps. The runner already has a key — the one it connected with — so the claim binds repo, ref, SHA, workflow blob, step digest, exit, log object, artifacts and the runner's own finishing time, and the server verifies it against the account the SSH channel authenticated as. Consequences: the runner must have its private key on disk (nothing signs arbitrary bytes through an agent), an unsigned finish is refused, and "signed" still only means "attributed" — a runner can lie about an exit code. Whether a required check must be attested by a particular runner is the next policy question, deliberately not answered here |
1181 | 2026-10-23 | A run log is uploaded by the runner, one object per attempt; nothing is streamed | Streaming made the server the author of bytes the runner produced: heartbeats shipped chunks, the server concatenated them in a SQLite column and hashed the result, so the archived object's id was the server's, not the writer's — and a signed record could never bind it. A log is now uploaded through the same put-object path as an artifact, and the store is the authority on whether the bytes exist. The cost is a live tail: nothing is visible until the attempt ends, and an attempt that is killed leaves no log at all (the attempt that hung is exactly the log somebody wants). Accepted, because a log that can be shown to be the runner's is worth more than one that arrives early |
1182 | 2026-10-23 | A review comment anchors on the blob of the content reviewed, and the page decides whether it is stale | A line number is not an identity: the file moves and the number points at different code. A commit id is too coarse — any unrelated change to the branch would invalidate every anchor. The blob is content-addressed, so a comment stays glued to exactly what was read, current for as long as the file is unchanged, and an anchor that lies can only make itself stale. It also means nothing has to be checked at the push boundary: the page resolves the blob and compares, so a later head never retroactively rejects an old comment |
1183 | 2026-10-23 | A review comment is an issue-comment file inside the request, not a new format | `requests/<ulid>/comments/<ulid>.md` is parsed by the same code as `issues/<ulid>/comments/<ulid>.md` (`issues.ParseComment`), so the thread inherited the validation, the author-edits-own-words rule and the markdown boundary instead of reimplementing them. A request's own file stays outside the ownership rule for the reason an issue's does: state, head and base are shared, and updating them is the author's normal edit |
1184 | 2026-10-23 | A finished run's record is committed to `refs/heads/vole` by the runner, one directory per attempt | A result the forge recorded is state a clone should carry, and `refs/heads/*` is what a plain clone fetches — the same reason issues live on a branch rather than a custom ref. The log and artifacts are LFS pointer files, so the sweep marks them by its ordinary walk and `pre-receive` pointer hygiene still applies. The row in `runs.db` becomes a mirror, which is the project's rule that git is authoritative and SQLite is derived — now true of results as well as issues |
1185 | 2026-10-23 | The runner signs the commit, and the commit is the attestation | The instance signing anything is the mistake that deleted the instance key: a statement only the box can make is worth nothing to a mirror or a third party. A run record is one commit per attempt, so the commit's SSH signature covers the record's tree (every field and every pointer), the parents and the message — one signature, no separate claim format to keep in step, and nothing the server has to assemble. Verified by `git verify-commit` against an allowed-signers file built from the pushing account's keys, which is also where identity comes from: the principal git reports is the account, not a name typed into a commit |
1186 | 2026-10-23 | Only the repository's runner allowlist may report, and only for the attempt it was leased | Whose build the host believes was already the rule for counting a check (`acl runners`, admins implicit); making the same list decide who may *write* a result means a non-runner cannot record one at all, and the allowlist is checked where every other policy is — `pre-receive`. The record must also describe the job the queue handed that account (run, attempt, commit, workflow, job, steps digest), or an allowed runner could report a green result for a commit it was never asked to build, or invent a higher attempt number and have the next rebuild believe it |
1187 | 2026-10-23 | A run's record and its release pointers are one commit and one push | The result and the artifacts it produced are one fact; splitting them lets a release carry bytes whose build has no record, and makes a run pay a push per output. Append-only falls out of the same rule: an added record is accepted, an edit or a deletion is a rewrite of evidence with no admin override, since the alternative to refusing is running the job again |
1188 | 2026-10-23 | Access rules live in `data/users.db`, not `refs/vole/acl` | The rules are configuration the instance owns: only the instance writes them, every request and every push reads them, and they are keyed by account id, so a copy in a clone cannot be read without this instance's roster. In git they cost a commit, a compare-and-swap retry and a rev-parse per read to store a handful of rows — the same argument that moved the roster out of `data/system.git`. What the ref did buy, surviving a deleted instance, a backup of one file does better. Existing instances import the ref once at startup (`repo.Store.ImportACLs`), and a change keeps its author in the row and a line in `data/audit.log`, which is what the commit message used to say |
1189 | 2026-10-23 | `runs.db` holds the queue; finished runs are rebuilt from the branch | A queued or leased run has no record because it has not happened, and that is exactly what a database is for. Once an attempt finishes, the record is the copy that matters, so `vole index rebuild` reconstructs the row, the attestation and the artifact list from git — the queue stops being the only place a result exists, which is what made run records non-portable |
1190 | 2026-10-23 | "Would this merge?" is answered in memory and surfaced, never gated | `merge-tree --write-tree` merges without a worktree or an index, so the cost is one git process on a page view; gating on it would be wrong because the base can move after an approval, and the roadmap asked for detection before the push rather than a second refusal. The exit status alone is ambiguous — 1 is both "conflicts" and "bad revision" — so the answer is believed only when the output starts with a tree id, and silence otherwise |
1191 | 2026-10-23 | The queue is reconciled from the refs, not persisted as a push-event log | The roadmap item asked for push events in SQLite, and a row there would not have been atomic with the ref update either: the hook is another process writing another file, so it shrinks the crash window instead of closing it, puts a database write into the path that must never fail a push, and dies with the same `rm data/runs.db` it was meant to survive. A ref tip is already the durable statement that a commit is live, so `ref_cursors` plus a 30-second pass answers the same question without a second copy of the fact — and the spool becomes a latency hint. The cost, accepted and written down: an emptied queue or a first start after an upgrade queues each current tip once |
1192 | 2026-10-23 | An idle lease parks on an in-process wake-up channel, and eligibility is cached for one poll window | The "long poll" was the client's own timer — the queue answered at once and the runner slept — and every ask re-derived the account's repositories by opening each one's rules, which is O(repos) per idle runner every two seconds. The wait is a hint and never a durability mechanism (the lease is still the database's decision), so a missed wake-up costs one poll interval; in-process is enough because one `vole serve` owns the queue, and a second process would have to ask the database anyway |
1193 | 2026-10-23 | A new tip cancels the queued runs of the same ref, and the lease serves the least busy repository first (partly superseded 2026-10-23: ordering is kept for fairness, and the cap is added beside it as `max_jobs_per_repo`) | Pushing twice in a minute built the first commit twice while `canceled` existed with no writer. The cancellation covers queued rows only: interrupting a running build is a different promise from not starting one. Fairness is ordering rather than a per-repository cap, because a cap is a number somebody has to tune and ordering needs none — a burst still runs, it just cannot take every runner |
1194 | 2026-10-23 | Runner presence is a queue row per channel, filtered by the clone rule | A runner was an SSH channel invisible to both the web and the queue, so "is my build waiting because nobody is listening" had no answer anywhere. Presence is derived rather than stored: written by connect, lease, heartbeat and finish, dropped when the channel closes, aged out after ten silent minutes. The web is anonymous, so a session appears only while the repository it is building is public — naming a private repository would confirm it exists |
1195 | 2026-10-23 | A record names the machine (OS, architecture), not the toolchain | The roadmap asked for the runner's Go version too, and it is the one field here that would mislead: `runtime.Version()` reports what compiled the *runner binary*, not what a job builds with, so in a signed record it would be a confident answer to a question nobody asked. OS and architecture describe the machine, which is the fact worth keeping — it is what turns "one macOS runner in a Linux pool" into something a finished run can answer. Both are the binary's build target rather than a live probe: a `uname` subprocess would add a failure path on Windows to sharpen a fact the binary already knows, and the only case it would correct is emulation. The keys are optional so records written before them still read, since a record is never rewritten |
1196 | 2026-10-23 | A repository may be bounded with `max_jobs_per_repo`, and finishing a job wakes the lease | Ordering and bounding answer different questions: ordering (least busy repository first) keeps one repository's burst from taking the whole fleet, and a cap is what bounds one repository's footprint on purpose. Enforcing it as a skip in the lease rather than a refusal keeps the other repositories an account may run serviceable, which makes `nil` mean two things — "nothing to do" and "work, but not for you yet" — and a cap-bound queue is exactly where the second meaning would hang forever, since a parked runner has nothing to wake it. So `Finish` notifies as well: a released slot is leasable work. The runners page renders `4 of 4 (at the cap)` for the same reason, because a depth that is full and not draining with no explanation is the question that page exists to answer |
1197 | 2026-10-23 | A runner's `--repos` filter is applied by the server, as an intersection with the allowlist | A filter and a grant are different things: `--repos` says what one machine is *for*, and the runner allowlist says what its account *may do*. Applying the filter server-side, against the grant, means the client's list can only shrink the answer — if the server honoured the request list directly, an SSH flag would have become an access-control decision, and the first person to notice would be whoever read the flag's name and assumed otherwise. An empty filter means "no narrowing" rather than "nothing" so an unfiltered runner keeps following allowlist edits instead of freezing at connect time. The cost is a filter that matches nothing, which presents as a healthy idle runner, so `ping` reports the grant and the runner refuses at startup instead. `Lease` consequently takes a struct: it now holds two lists of names, and adjacent `[]string` arguments are the shape that transposes silently |
1198 | 2026-10-23 | `runs-on` labels are pinned at enqueue, and a runner's labels are a claim rather than a grant | Pinning reuses the rule that already governs steps and artifact patterns: a workflow edited after a push must not change what an already-queued job is. That settles the derived-store question too — `run_labels` is queue state, not history, because a finished run's requirement is recoverable from the pinned workflow blob, so `index rebuild` does not restore labels and nothing needs it to. The asymmetry with `--repos` is the interesting part: a runner's labels are a claim about itself, so lying only wins it work it will do badly, while a runner's repository list is enforced by the server because an SSH flag must not become an access-control decision. Both are in the same lease request and neither is trusted for the same reason. Labels are lowercase and validated by `pre-receive` and by the runner with one shared rule, because a label no runner can carry leaves a job queued forever behind an error nobody sees |
1199 | 2026-10-23 | LFS locking rides the SSH transfer protocol, and the refusal lives in `pre-receive` | The HTTP locking API is a write surface, and this instance has none: HTTP is anonymous read-only. The SSH adapter defines `lock`, `list-lock` and `unlock`, so locking costs no new authentication — the key that pushed is the key that locks. Where the refusal lives is the part worth remembering. git-lfs checks locks on the *client*, which means a plain `git push`, or a client with its hook disabled, walks past a lock entirely — so the rule also sits in `pre-receive`, at the one place this forge enforces everything else. The client-side check becomes a faster error message rather than the authority. Narrow on purpose: only the paths the push writes, so branching and merging untouched history are unaffected. Admin override is audited, for the same reason force-push is: a lock whose holder has left would otherwise make a path unpushable forever |
1200 | 2026-10-23 | An LFS object is served only through a repository that holds a link to it | The store is content-addressed, so a plain lookup by id let anybody who knew a sha256 read another repository's artifact through a repository whose read rule they passed. Two fixes were available and one was rejected: an oid→repo table would have made the read path depend on derived state, so deleting index.db — which the project promises loses nothing — would have broken every LFS fetch until a rebuild. Hard links into a per-repository namespace keep the promise (the links are reconciled from the refs, so they are rebuildable too) and keep the deduplication (one copy of the bytes, two directory entries). The consequences are the interesting part: pre-receive has to check pointers against the *pushing* repository or a cross-repo pointer passes the push and 404s on clone; the sweep has to walk per repository, which makes it the repair tool as well; and an instance upgrading from the shared pool has no namespaces, so the server runs the link pass on first boot rather than leaving every fetch broken behind an upgrade note |
1201 | 2026-10-23 | One trust model: attribution at the push boundary, one log of what the boundary decided | The system had two answers to "who did this": runner results were attributed by a commit signature, and issues, comments, approvals and every push by the account whose key the SSH handshake resolved. Keeping both meant keeping allowed_signers generation, find-principals semantics, a --key contract in the runner, a key column in the queue and a pre-flight key check — and the obligation to keep all of it agreeing with the mechanism used everywhere else. The signature lost on three counts: it restated the handshake (the runner signs with the key it already pushes with, so the only thing it added was portability nobody consumes), it proved attribution rather than truth (a runner can sign a lie), and an unverified assurance mechanism is a liability because it looks like security. What replaced it is the thing a trust-the-box model actually requires: a push log. post-receive records one line per accepted ref update — ref, old, new, account, repo — in the same append-only log as the privileged acts, because if attribution is the boundary's then the instance owes a reader a record of its decisions. post-receive and not pre-receive, because pre-receive returning 0 is a permission and post-receive is the event, and it must never fail a push. The cost is stated in the roadmap: a clone can no longer show who pushed what, and that is the wrong trade for an instance mirrored outside its operator's control — at which point the answer is per-repository mandatory signing implemented once, not a second mechanism kept warm |
1202
1203 ## Open questions
1204
1205 - Email deliverability: rely on operator SMTP, or bundle a fallback? Leaning operator SMTP
1206 only, with a loud warning when unconfigured. This is really a Phase 8 question now: it
1207 exists only to serve the notifications Phase 2 deliberately did not build.
1208
1209 Answered since this list was written, kept here so the answers stay findable:
1210
1211 - **The workflow dialect is Vole's own minimal schema**, not a subset of GitHub Actions —
1212 a subset that silently differs from the real thing is worse than a clearly separate one.
1213 `runs-on` (Phase 4 slice 12) was added on that basis: one label or a list, lowercase,
1214 validated at push, with its own error messages rather than GitHub's.
1215 - **SSH shipped first and answered itself.** It arrived in Phase 1, and HTTP ended up
1216 read-only, so there was never a "should SSH come early" trade to make: SSH is the only
1217 write transport and HTTP is anonymous read.
1218 - **Change-request approvals needed no authenticated write surface.** They are push-based:
1219 `vole request approve` commits an approval file on `refs/heads/vole`, and `pre-receive`
1220 refuses any approval whose name is not the account the SSH key resolved to. The web
1221 stayed read-only, which is the decision that made the rest of the phase small.