vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
The roster is a database, not a repository
427c47611f7db09e52618a0f2d05291a8641b2d0 · Keenan Nemetz · 17d ago · parent 0f442b1c
The roster is a database, not a repository data/system.git is gone. Accounts, every name they have held and their SSH keys live in data/users.db, next to the other authoritative store (runs.db) and the disposable index. users id, name, status, created user_names every name ever held -- primary key, so a name is issued once user_keys fingerprint, type, line -- primary key, so a key has one owner Two invariants that used to be parse-time checks are now table keys, and a roster change is one transaction instead of a commit, a CAS retry and a tree walk. The snapshot, the five-second reload and the audit line per change are unchanged, so `vole user add-key` still applies without a restart and revocation is still immediate. Clients no longer clone the roster, because there is nothing to clone. The one view a client needs -- active id and name -- is served by a `vole-roster` SSH verb over the key it already pushes with, so name resolution gained no endpoint and no second credential. `vole request new --author keenan` now resolves through that verb (TestRequestCLIResolvesNamesOverSSH: the request landed with author_id = the account id, and an unknown name fails before pushing). A WAL database cannot be backed up with cp, so `vole user backup <path>` takes a consistent copy with VACUUM INTO (TestRosterIsOneDatabaseFile opens the copy and resolves a renamed account and its key from it). This replaces the "identity survives without a side database" criterion, which is withdrawn rather than left looking unmet: the roster is now exactly one file, and the backup is what proves it is not lost with the index. Verified through the binary: init -> create -> add-key -> rename -> list -> backup, the restored copy listing the same account, former name and key; then scripts/ci.sh green (gofmt, vet, build, markdown links, go test -race ./...).
docs/roadmap.md +41−20
| @@ -28,7 +28,7 @@ throughout; see [Non-goals](#non-goals) for what they rule out. | |||
| 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 | | |
| 30 | 30 | | Index store | SQLite via `modernc.org/sqlite` | CGO-free; rebuildable, so swapping engines later is cheap | | |
| 31 | | Identity | Roster as `<user>.keys` files in `data/trusted_keys/` | `authorized_keys` format; nothing secret, ops-manageable, and ~280 lines less than a git-backed roster | | ||
| 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 | 32 | | Web authentication | None: the web is read-only | No password store, no login, no session tokens | | |
| 33 | 33 | | Secrets | Instance key + SSH host key, `0600` on disk | A private key in a repo would be cloned and eventually pushed | |
| 34 | 34 | | Config | TOML on disk, no database-backed settings | One box, one file, greppable | | |
| @@ -95,9 +95,13 @@ The unglamorous foundation. If this phase isn't solid, nothing above it is trust | |||
| 95 | 95 | | A session cannot be used as a shell | `TestExecWhitelistRefusesShell` (incl. `sh -c`), `TestShellRequestRefused`, `TestRepoOutsideTheStoreIsRefused` | | |
| 96 | 96 | | Unregistered key cannot connect | `TestUnknownKeyRejected` | | |
| 97 | 97 | | `vole index rebuild` reproduces state after deleting the index | `TestRebuildIndexAfterDelete` | | |
| 98 | | Identity survives without a side database | `TestIdentitySurvivesWithoutSideDatabase` — a plain clone of `system.git` is the whole roster | | ||
| 98 | | 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 | | ||
| 99 | 99 | | Private repos are invisible, not forbidden | `TestPrivateRepoIsInvisible` — 404 on web and HTTP, absent from the index, still cloneable over SSH | | |
| 100 | 100 | ||
| 101 | **Withdrawn criterion:** "Identity survives without a side database". The roster is | ||
| 102 | now exactly one database file (`data/users.db`); what it used to promise — that the | ||
| 103 | roster is not lost with the index — is what the backup criterion above measures. | ||
| 104 | |||
| 101 | 105 | **Revision after Phase 1 shipped.** The first cut authenticated HTTP with | |
| 102 | 106 | passwords and PATs, and the web had logins and settings pages. That was | |
| 103 | 107 | replaced, not extended: passwords were dropped, writes moved to SSH, and the web | |
| @@ -114,13 +118,15 @@ Deviations from the original plan, and why: | |||
| 114 | 118 | true by construction. | |
| 115 | 119 | - **`refs/vole/*` is rejected for client pushes.** The ACL lives there, so | |
| 116 | 120 | permitting client writes would let a writer self-grant admin. | |
| 117 | - **The roster is a trusted-keys directory, not a git repo.** It started on | ||
| 118 | `refs/heads/main` in `data/system.git` (a custom namespace is invisible to | ||
| 119 | `git clone`, so `refs/vole/users` would have produced empty backups). It was | ||
| 120 | then replaced outright by `data/trusted_keys/<user>.keys`: two of the three | ||
| 121 | fields in `meta.json` were unused — ACLs key on username, and nothing ever set | ||
| 122 | `disabled` — so the roster was really just `username -> authorized_keys lines`, | ||
| 123 | which is what sshd has always kept in a file. See the decision log. | ||
| 121 | - **The roster is a SQLite database, not a repository and not a key directory.** | ||
| 122 | Its first shape was `refs/heads/main` in `data/system.git`; the second was | ||
| 123 | `data/trusted_keys/<user>.keys` files. The git version paid for a commit, a CAS | ||
| 124 | retry and a parse step per roster read to store a handful of rows, and the | ||
| 125 | directory version could not hold the stable account id that ACLs and approvals | ||
| 126 | key on without becoming a database in spirit. `data/users.db` holds the same | ||
| 127 | facts (id, name, every name ever held, status, keys) with the invariants as table | ||
| 128 | keys, and the client read that `system.git` provided is now the `vole-roster` SSH | ||
| 129 | verb. See the decision log. | ||
| 124 | 130 | - **No WebAuthn.** It requires a secure context, which would have pulled TLS | |
| 125 | 131 | forward from Phase 6 to authenticate a web surface that no longer accepts | |
| 126 | 132 | writes. | |
| @@ -224,20 +230,34 @@ prior approval, an approval for `main` cannot be repointed at another branch, a | |||
| 224 | 230 | self-approval or impersonated approval is refused, an admin bypass works, and each merge | |
| 225 | 231 | is written to the audit log with the request id and approvers. | |
| 226 | 232 | ||
| 227 | ### Phase 3.5 — the system repository | ||
| 233 | ### Phase 3.5 — the roster | ||
| 228 | 234 | ||
| 229 | ✅ **Done.** `data/system.git` is the instance repository. Its `main` branch holds | ||
| 230 | the user roster (`users/<ulid>/meta.json`: name, status, keys, former names). | ||
| 235 | ✅ **Done.** `data/users.db` is the user roster: one account per row with a stable | ||
| 236 | id, a mutable name, a status and its SSH public keys. `user_names` holds every name | ||
| 237 | an account has ever held, so SQLite itself guarantees a name is issued once; | ||
| 238 | `user_keys` is keyed by fingerprint, so a key cannot belong to two accounts. | ||
| 231 | 239 | ||
| 232 | 240 | - Accounts have a stable id; names are mutable, and a name is never issued twice, so | |
| 233 | 241 | a new account cannot inherit a retired one's ACL entries or approvals. | |
| 234 | 242 | - ACL entries and approval files are keyed by account id, so `vole user rename` is a | |
| 235 | one-file change that disturbs nothing else. | ||
| 243 | one-row change that disturbs nothing else. | ||
| 236 | 244 | - The roster is polled every five seconds, so `add-key` and `retire` take effect | |
| 237 | 245 | without a restart. | |
| 238 | - Planned, not built: the instance landing page served from the same repository | ||
| 239 | (`pages/` on `main`), and exposing the roster read-only over SSH/web so a reader | ||
| 240 | can verify it without server access. | ||
| 246 | - A clone has no copy of the roster. The CLI asks the instance for the id/name map | ||
| 247 | over the SSH key it already pushes with (`vole-roster`, one tab-separated line per | ||
| 248 | active account), so name resolution needs no endpoint and no second credential. | ||
| 249 | - `vole user backup <path>` takes a consistent copy with `VACUUM INTO` — copying | ||
| 250 | `users.db` by hand while the instance runs can miss the `-wal`. Measured: | ||
| 251 | `TestRosterIsOneDatabaseFile` backs up a live instance's roster, opens the copy and | ||
| 252 | resolves a renamed account and its key from it. | ||
| 253 | |||
| 254 | **The system repository was withdrawn, not deferred.** `data/system.git` held the | ||
| 255 | roster on `refs/heads/main` with a per-account `users/<ulid>/meta.json`, plus | ||
| 256 | git-served read access for clients. It earned none of its parts: every roster change | ||
| 257 | was a commit on a second repository that existed only for this, the client read was a | ||
| 258 | `git fetch` of a repo that held nothing else, and the same invariants (one name, one | ||
| 259 | key) fell out of table keys instead of a parse step. The instance landing page it was | ||
| 260 | also meant to host is served from trees by Phase 6, so nothing else depended on it. | ||
| 241 | 261 | ||
| 242 | 262 | ### Phase 4 — CI/CD | |
| 243 | 263 | ||
| @@ -539,7 +559,7 @@ tag marked annotated, the download returned the bytes, and `git log vole` read | |||
| 539 | 559 | Planned: | |
| 540 | 560 | ||
| 541 | 561 | - Identity without ceremony: `vole whoami` resolves the key a person pushes with to an | |
| 542 | account (the roster is readable over SSH at `system.git`), and `vole request approve` | ||
| 562 | account (the roster is one SSH verb away, `vole-roster`), and `vole request approve` | ||
| 543 | 563 | stops requiring `--as`. The name someone types has to match their key anyway, so | |
| 544 | 564 | asking for it is a trap rather than a check. | |
| 545 | 565 | - Filing an issue or opening a request without a clone (`--remote ssh://…`), because | |
| @@ -689,8 +709,8 @@ Filled in as phases land. Append; don't rewrite. | |||
| 689 | 709 | | 2026-10-22 | Bad credentials return 401 with a challenge | 403 leaked repo existence and suppressed client retry | | |
| 690 | 710 | | 2026-10-22 | Writes move to SSH; HTTP becomes anonymous read-only | Removes password storage, login, sessions and the token table outright | | |
| 691 | 711 | | 2026-10-22 | Web authentication dropped; the web is read-only | Read-only content is exactly what an anonymous principal may see | | |
| 692 | | 2026-10-22 | Identity in `system.git`, roster on `refs/heads/main` | Public keys only; a plain clone is a complete, non-leaking backup | | ||
| 693 | | 2026-10-22 | Roster replaced by a `trusted_keys/` directory | The git-backed version carried unused fields and ~280 lines of CAS/tree machinery; the only real loss was audit, now `data/audit.log` | | ||
| 712 | | 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 | | ||
| 713 | | 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` | | ||
| 694 | 714 | | 2026-10-22 | `receive.maxInputSize` replaces the HTTP byte cap | git enforces it on both transports; the proxy never buffers a pack | | |
| 695 | 715 | | 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 | | |
| 696 | 716 | | 2026-10-22 | Issue trees validated in `pre-receive` | Reject bad data at the boundary instead of rendering it | | |
| @@ -701,7 +721,7 @@ Filled in as phases land. Append; don't rewrite. | |||
| 701 | 721 | | 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 | | |
| 702 | 722 | | 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 | | |
| 703 | 723 | | 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 | | |
| 704 | | 2026-10-22 | The roster lives in `data/system.git` on `refs/heads/main` | One authoritative copy, portable and auditable with plain git; per-repository copies would make revocation non-atomic | | ||
| 724 | | 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 | | ||
| 705 | 725 | | 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 | | |
| 706 | 726 | | 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 | | |
| 707 | 727 | | 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 | | |
| @@ -721,6 +741,7 @@ Filled in as phases land. Append; don't rewrite. | |||
| 721 | 741 | | 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 | | |
| 722 | 742 | | 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 | | |
| 723 | 743 | | 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 | | |
| 744 | | 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 | | ||
| 724 | 745 | | 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 | | |
| 725 | 746 | ||
| 726 | 747 | ## Open questions | |