- Feature Name: `fossil-capability-compat` (fossil's caps model, native in quarry)
- Start Date: 2026-08-22
- Status: Implemented
- 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
[summary]: #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
[motivation]: #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:

1. Every authorization decision already flows through named chokepoints —
   the per-op `Auth` field, `requiredRole()`, `sessionPlane.Allow`,
   `Verify`, `CanRead`, `TrustedAuthor`. No page checks permissions ad hoc.
2. The password material is already compatible: fossil's stored `user.pw`
   is the RFC §6.3 shared-secret derivation that `internal/xfer` implements
   today for sync auth.

## The model
[model]: #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
[op-table]: #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
[storage]: #storage--plumbing-changes

### OLTP (internal/oltp)

```sql
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:

```go
// 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
[import]: #import--transport

1. **Host-from-file** (primary, zero-setup): `zit import-fossil` reads the
   fossil SQLite `user` table (`login, cap, pw, info, mtime`) and the
   category rows, and emits them; quarry ingest writes `repo_caps`,
   `repo_category_caps`, and accounts (creating quarry accounts with
   `legacy_pw` when the login doesn't exist; never overwriting an existing
   quarry account's password — collision rule: caps merge by union,
   credentials keep quarry's).
2. **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.
3. **Login compat**: login verifier tries argon2, then `legacy_pw`
   (= §6.3 hash via `xfer.SharedSecret`); success rehashes to argon2 and
   clears `legacy_pw`.

## Divergences, stated
[divergences]: #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) and `l`/`q` (append moderation for
  wiki/tickets): out of scope v1; letters stored.
- quarry keeps weekday dates, identicons, etc. — presentation, no caps.

## Testing
[testing]: #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 ≠ u` gets 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, assert `EffectiveCaps` per 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
[build-order]: #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
[open-questions]: #open-questions

- Should quarry's stock `nobody` include `h` (links for everyone, current
  behavior) or match fossil's antibot default? Proposal: quarry default
  keeps `h`; imported repos keep their own answer.
- Mirror admin-credential storage UX (per-mirror secret field in settings).
- Whether `forum.announce` should require a distinct `A` grant once email
  announcements exist.
