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.
Phase 4: access rules live in the instance database
ec5c519cb9fc4277baa8263cfd31891a95f1d7ac · Keenan Nemetz · 16d ago · parent 90fdaf39
Phase 4: access rules live in the instance database The ACL was one acl.json on refs/vole/acl: a ref only the server writes, read on every push and every request. It is configuration the instance owns, keyed by account id, and keeping it in git cost a commit, a compare-and-swap retry and a rev-parse per read to store a handful of rows — the argument that already moved the roster out of data/system.git, applied to the rules that name its accounts. It is now a row in data/users.db beside the roster, in the same file as the ids it refers to: - access.Store: repo_acl(repo_id, acl, updated, updated_by), one row per repository, keyed by the stable repository id so a rename moves nothing. - Meta.ACL reads the row; a repository with no row is deny-everything, exactly what a missing ref meant. - Meta.SetACL is one upsert, so SQLite orders concurrent edits and the CAS loop with its retries is gone. The author stays in the row and in data/audit.log, which is what the commit message used to say. - Deleting a repository forgets its rules, after the directory is gone. - repo.Store.ImportACLs reads refs/vole/acl once at startup for any repository without a row, so an existing instance keeps its rules. It never overwrites a row, and a second run imports nothing. - The index's acl_sha is a digest of the rules rather than the ACL commit id. - pre-receive reads the rules from VOLE_USERS_DB and makes GIT_DIR absolute before deriving the repository from it, since git hands a hook ".". What the ref bought — rules that travel with a clone — is what this gives up, deliberately: the rules are account ids, so a copy in a clone is unreadable without this instance's roster, and the backup that holds the accounts now holds them too. Measured: internal/repo imports rules from a real refs/vole/acl commit and is idempotent on a second run; internal/access covers round-trip, replacement, deletion and the audit line; the full suite — auth, site visibility, transport and pushes — runs against rules read from SQLite.
docs/roadmap.md +5−3
| @@ -23,7 +23,7 @@ throughout; see [Non-goals](#non-goals) for what they rule out. | |||
| 23 | 23 | | State model | Git is authoritative; SQLite is a derived read model | Rebuildable index means storage is a swappable detail | | |
| 24 | 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 | 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 | Sharded sidecar refs (`refs/vole/issues/<shard>`, `refs/vole/crs`, `refs/vole/acl`) | Parallel writers, no single-ref contention, still fetchable as one glob | | ||
| 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 | 27 | | Metadata writes | Compare-and-swap ref updates, retry on loss | No lock server, no lost updates | | |
| 28 | 28 | | ACL enforcement | `pre-receive` only | One enforcement point; the web has no writes to gate | | |
| 29 | 29 | | Binary layout | One static CGO-free binary, three modes (`serve`, `runner`, `hook`) | Drop-anywhere deployment | | |
| @@ -78,7 +78,8 @@ The unglamorous foundation. If this phase isn't solid, nothing above it is trust | |||
| 78 | 78 | - Push size cap (default 1 GiB) and a push concurrency semaphore — a 1 GiB push | |
| 79 | 79 | peaks at ~1.13 GB of git-child RSS, so this is a memory bound, not politeness | |
| 80 | 80 | - Users, sessions, PATs (hashed at rest, scoped, revocable) | |
| 81 | - ACL v1: owner / write / read, stored on `refs/vole/acl` | ||
| 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) | ||
| 82 | 83 | - `pre-receive` + `update` hooks enforcing ACLs, protected branches, and object size limits | |
| 83 | 84 | - Web UI: repo list, tree/blob browser, raw view, commit log | |
| 84 | 85 | - ~~Instance signing key generated at first boot and persisted~~ — removed. Nothing | |
| @@ -874,7 +875,7 @@ Filled in as phases land. Append; don't rewrite. | |||
| 874 | 875 | | 2026-10-22 | Push size cap + concurrency semaphore in Phase 1 | Spike B: 1 GiB push peaked at 1.13 GB git-child RSS | | |
| 875 | 876 | | 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 | | |
| 876 | 877 | | 2026-10-22 | `pre-receive` is the only policy hook | One enforcement point; all-or-nothing, so a refusal can never half-apply | | |
| 877 | | 2026-10-22 | Clients may not push `refs/vole/*` | The ACL lives there; allowing it would let a writer self-grant admin | | ||
| 878 | | 2026-10-22 | Clients may not push `refs/vole/*` | The ACL lived there, and allowing it would let a writer self-grant admin. The prefix stays refused as a guard even though nothing is written there now (see the 2026-10-23 row about `data/users.db`) | | ||
| 878 | 879 | | 2026-10-22 | SQLite via `modernc.org/sqlite` (two files: auth, index) | CGO-free single binary; the index file is disposable, auth is not | | |
| 879 | 880 | | 2026-10-22 | Bad credentials return 401 with a challenge | 403 leaked repo existence and suppressed client retry | | |
| 880 | 881 | | 2026-10-22 | Writes move to SSH; HTTP becomes anonymous read-only | Removes password storage, login, sessions and the token table outright | | |
| @@ -933,6 +934,7 @@ Filled in as phases land. Append; don't rewrite. | |||
| 933 | 934 | | 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 | | |
| 934 | 935 | | 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 | | |
| 935 | 936 | | 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 | | |
| 937 | | 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 | | ||
| 936 | 938 | | 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 | | |
| 937 | 939 | | 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 | | |
| 938 | 940 | ||