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