Sign in

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

vole
Clone

Read only, no credentials:

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

Write over SSH with a registered key:

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

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

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