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. | ||