Artifact 87a23abcadd2e40fae43a45f12ebf1c43d23f44740b3a5118e29626798cc3306:
- File docs/rfcs/0004-fossil-capability-compat.md — part of check-in [6bb1c20891] at 2026-08-22 14:13:28 on branch trunk — RFC 0004: fossil capability compatibility — plan (user: dmestas, size: 12126) [annotate] [blame] [check-ins using]
- Feature Name:
fossil-capability-compat(fossil's caps model, native in quarry) - Start Date: 2026-08-22
- Status: Draft for review
- Depends on: RFC 0001 §7/§8 (auth model, OLTP), zit issues filed per §10
- Source authority: fossil
www/caps/suite (mirrored in~/fossil-rfc/knowledge-base/raw/caps-*), RFC §6.3 (shared secret)
Summary
A fossil repository hosted on quarry brings its users and their exact
capabilities with it, with no further setup. quarry replaces its ordered
four-role model (read < write < admin) with fossil's capability model as the
native authorization vocabulary: per-account-per-repository cap strings
(gjorz, +hmnc, …), the four fossil user categories as quarry's defaults,
and every quarry op gated by the capability letters that gate the equivalent
fossil surface. Where a capability exists in both systems it carries over
exactly; where it exists in only one, the rule is stated per letter in §6.
The one caveat is fossil's own: the user table lives in the repository
database, not the artifact DAG, and fossil does not give it to anonymous
cloners. The zero-setup promise therefore holds fully on the
host-from-file path (zit import-fossil — the table is in the SQLite
file) and on mirrors requires the owner's admin credential once
(config pull user). We do not and must not bypass that boundary.
Motivation
"Host your fossil repo on quarry" is quarry's founding pitch. Today that migration silently drops every user and every permission: accounts must be re-created, roles re-granted, and quarry's four roles cannot express "wiki editor who cannot commit" at all — a shape fossil supports natively and real projects use. Import fidelity for content is already proven by the disposability harness; this RFC extends the same standard to people.
Two facts make this a refactor rather than a rewrite:
- Every authorization decision already flows through named chokepoints —
the per-op
Authfield,requiredRole(),sessionPlane.Allow,Verify,CanRead,TrustedAuthor. No page checks permissions ad hoc. - The password material is already compatible: fossil's stored
user.pwis the RFC §6.3 shared-secret derivation thatinternal/xferimplements today for sync auth.
The model
Cap strings
An account's authority on a repository is an unordered, case-sensitive string
of capability letters, exactly fossil's vocabulary (caps-ref). Stored
per (account, repo) in OLTP. Letters quarry does not implement are
stored but inert — imports are lossless even where surfaces are missing.
Categories
Fossil's four categories become quarry's defaults, seeded per repository and editable in settings:
| Category | Stock caps | quarry meaning |
|---|---|---|
nobody |
gjorz |
every visitor, no session |
anonymous |
hmnc (adds to nobody) |
any signed-in account with no explicit caps |
reader |
kptw (adds; cap u inherits it) |
replaces role read's successor semantics |
developer |
ei (adds; cap v inherits it) |
replaces role write's commit half |
Effective caps = individual caps ∪ nobody ∪ (logged-in ? anonymous : ∅)
∪ (u ? reader : ∅) ∪ (v ? developer : ∅). v does not imply u —
fossil's rule, kept deliberately, tested explicitly.
a grants everything except s, x, y (fossil's Admin-vs-Setup rule).
s grants everything. quarry's account.admin flag maps to s on every
repository (unchanged semantics, new spelling).
Private repositories
Fossil "takes a repository private" by blanking nobody and anonymous.
quarry's private-visibility flag becomes exactly that: visibility=private
⇔ those two category strings are empty for the repo, and CanRead becomes
"effective caps contain any of gjoh2r". The invisible-not-403 posture is
unchanged — a reader with no read-capability cannot distinguish the repo
from a nonexistent one.
Op → capability table
The single normative artifact of this RFC. Caps is an OR-set: possession
of any listed letter passes. (s) marks site/setup scope (repo-less ops).
Ops absent from fossil keep the nearest fossil letter so imported users get
coherent behavior (rule stated in §6).
| quarry op | Caps | fossil surface |
|---|---|---|
| timeline.list, tree.list, file.get, file.history, checkin.get, checkin.diff, branch.list, tag.list, artifact.resolve, repo.counts, repo.about, repo.get, search.query | o |
Read / check-out |
| archive.get | z |
/zip /tarball |
| — sync clone/pull (xfer layer) | g |
Clone |
| — rendered hyperlinks (web layer, §7.4) | h |
Hyperlink antibot |
| wiki.list, wiki.get, wiki.history | j |
RdWiki |
| wiki.save (new page) | f |
NewWiki |
| wiki.save (existing page) | k |
WrWiki |
| technote.list/get | j |
technotes ride wiki caps |
| technote.add | k |
(fossil: technote edit needs WrWiki-class) |
| ticket.list, ticket.get, ticket.config(read) | r |
RdTkt |
| ticket.new | n |
NewTkt |
| ticket.set (comment) | c |
ApndTkt |
| ticket.set (fields) | w |
WrTkt |
| attachment.get | r/j per target |
Attach targets |
| forum.list, forum.get, forum.modlog | 2 |
RdForum |
| forum.post, forum.reply, forum.vote, forum.poll, forum.subscribe | 3 |
WrForum |
— staged-posting bypass (TrustedAuthor) |
4 |
WrTForum |
| forum.queue, forum.moderate, forum.hide, forum.moderation | 5 |
ModForum |
| forum.announce | 5 + A spirit; require 5 |
Announce is email-ish; 5 gates |
| chat.history / chat post | C |
Chat |
| me.feed, account.passwd(self) | p + login |
Password/self |
| people.list (emails/PII surfaces) | e for PII fields; list itself h |
RdAddr |
| repo.update, repo.reindex, ticket.config(write), mirror.set/detach/writes/run | a |
Admin |
| repo.add, repo.delete, repo.remove, account.add/delete/grant/revoke/list, fleet.status, fleet.sync | s (site) |
Setup |
| — sync push (xfer) | i + §6.3 secret |
Write/check-in |
| — private-branch sync | x |
Private (stored, inert until zit surfaces it) |
Stored-but-inert letters at ship: b B t q l m 6 7 y D d u v-internals where
noted, i.e. anything whose row above doesn't exist yet keeps its letter in
storage and does nothing. When quarry grows the surface, the letter starts
working with no migration.
Storage & plumbing changes
OLTP (internal/oltp)
CREATE TABLE IF NOT EXISTS repo_caps ( -- replaces repo_role's meaning
account TEXT NOT NULL,
repo TEXT NOT NULL,
caps TEXT NOT NULL, -- fossil cap string, verbatim
PRIMARY KEY (account, repo)
);
CREATE TABLE IF NOT EXISTS repo_category_caps ( -- per-repo category defaults
repo TEXT NOT NULL,
category TEXT NOT NULL, -- nobody|anonymous|reader|developer
caps TEXT NOT NULL,
PRIMARY KEY (repo, category)
);
-- account gains: legacy_pw TEXT DEFAULT '' (imported §6.3 hash; cleared on
-- first successful login after argon2 rehash)
Migration: existing repo_role rows translate once at open —
read→u, write→uv i? No: write→v plus u (quarry's write always
included read) — i.e. read→u, write→uv, admin→a. repo_role is kept
readable for one release, then dropped. PurgeRepo gains the new tables.
ops (internal/ops)
Op gains Caps string (OR-set). Auth stays for router-level coarse
gating (Anonymous/Token/Admin unchanged as a floor), Caps refines it.
Registry exposes CapsOf(op). ops.User unchanged.
Deciders (cmd/quarry, internal/web)
One new function is the heart of the change:
// EffectiveCaps: individual ∪ category expansion (§3), memoized per
// (account, repo) per request.
func (db *DB) EffectiveCaps(account, repo string) CapSet
sessionPlane.Allow(user, repo, need) → Allow(user, repo, op) checking
EffectiveCaps ∩ CapsOf(op). requiredRole deleted. Verify (Basic/sync)
gains the same check. Web mayAt/gateAt take an op or cap set instead of a
level; CanRead = "any of gjoh2r"; TrustedAuthor = "has 4 or 5".
The h cap (web only)
Fossil's antibot rule. quarry implements it at the render layer: when the
viewer lacks h, pages render with hyperlinks as plain text (fossil's
behavior), not by withholding content. Off by default for existing quarry
repos (nobody keeps h in quarry's default nobody string — see §6 defaults
divergence note); imported fossil repos keep whatever their table says.
Import & transport
- Host-from-file (primary, zero-setup):
zit import-fossilreads the fossil SQLiteusertable (login, cap, pw, info, mtime) and the category rows, and emits them; quarry ingest writesrepo_caps,repo_category_caps, and accounts (creating quarry accounts withlegacy_pwwhen the login doesn't exist; never overwriting an existing quarry account's password — collision rule: caps merge by union, credentials keep quarry's). - Mirror:
zit config pull user(same channel as shun) with the owner's upstream admin credential, stored per-mirror like sync creds; re-pulled on the mirror schedule so upstream user changes track. - Login compat: login verifier tries argon2, then
legacy_pw(= §6.3 hash viaxfer.SharedSecret); success rehashes to argon2 and clearslegacy_pw.
Divergences, stated
- quarry-only surfaces (votes, polls, subscriptions, moderation log page)
key off the nearest fossil letter (
3,2) — both-ways rule satisfied. - fossil letters with no quarry surface stay inert (§4 list); the settings page shows them greyed with "not yet surfaced" so imports are auditable.
- Login-groups: out of scope v1 (single-repo caps only).
6(AdminForum trust checkbox) andl/q(append moderation for wiki/tickets): out of scope v1; letters stored.- quarry keeps weekday dates, identicons, etc. — presentation, no caps.
Testing
- The matrix is the spec: a table-driven harness test encoding the fossil capability matrix (docs: the two DP-security-matrix diagrams) — for each (category, surface) cell, drive the web/API surface and assert allow/deny. Structural hooks per the no-copy-pins rule.
v ≠ ugets a named test. Admin-vs-Setup exclusions (s x y) get one.- Import round-trip: build a fossil repo with
fossil(or fixture DB), set users/caps, import, assertEffectiveCapsper user; login with the fossil password succeeds once and rehashes. - Private-repo blanking: import a "taken private" repo → invisible to anonymous on quarry.
- Disposability: caps live in OLTP (authoritative non-DAG state, 0001 §8) — the wipe/reingest harness must show caps unaffected by index rebuilds.
Build order
| Phase | Work | Where |
|---|---|---|
| 0 | zit: import-fossil emits user table; config pull user; zit users read verb |
danmestas/zit (2 issues) |
| 1 | OLTP caps tables + EffectiveCaps + migration from repo_role | quarry |
| 2 | ops.Caps on all ~45 ops per §4 table; deciders swapped; requiredRole deleted | quarry |
| 3 | web gates (mayAt/gateAt/CanRead/TrustedAuthor) on caps; h render rule |
quarry |
| 4 | import wiring + legacy login verifier | quarry (needs 0) |
| 5 | matrix harness test + settings UI for caps/categories | quarry |
Phases 1–3 land behind no flag — the migration makes existing grants equivalent, and the suite gates each phase. Phase 4 waits on zit.
Open questions
- Should quarry's stock
nobodyincludeh(links for everyone, current behavior) or match fossil's antibot default? Proposal: quarry default keepsh; imported repos keep their own answer. - Mirror admin-credential storage UX (per-mirror secret field in settings).
- Whether
forum.announceshould require a distinctAgrant once email announcements exist.