Skip to content
quarry Artifact [3d6115a359]
Login

Artifact [3d6115a359]

Artifact 3d6115a359f01af28e5cbad362f75261b8fb6085b2f29d0222e51a5194c0b1db:


// Package api owns RegisterAll — the one explicit wiring point where every
// op joins the registry (0003 §2: no init() magic; the wiring stays
// greppable) — plus the HTTP router that serves the registered ops. This
// is the sanctioned place Run closures over the data planes are built;
// the arch gate keeps every other consumer on the op side of the seam.
package api

import (
	"bytes"
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"html"
	"log"
	"net/url"
	"os"
	"path"
	"regexp"
	"sort"
	"strings"
	"sync"
	"time"
	"unicode/utf8"

	"craftdesign.group/quarry/internal/archive"
	"craftdesign.group/quarry/internal/chat"
	"craftdesign.group/quarry/internal/diff"
	"craftdesign.group/quarry/internal/errlog"
	"craftdesign.group/quarry/internal/highlight"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/ingest"
	"craftdesign.group/quarry/internal/markup"
	"craftdesign.group/quarry/internal/ops"
	"craftdesign.group/quarry/internal/work"
	"craftdesign.group/quarry/internal/zitcli"
)

// Repo is one registered store's data-plane pair.
type Repo struct {
	Index *index.Index
	Zit   *zitcli.Client
	// Leaves answers which check-ins are open leaves, from zit rather
	// than from a query over the index: repo-state is zit's to define
	// and it already caches the answer. nil leaves every entry unmarked
	// rather than marked WRONGLY, which is the safer failure.
	Leaves func() (map[string]bool, error)
	// FossilClone reports that this store holds no sub-manifest
	// artifact and can therefore be cloned by stock fossil. Probed once
	// at registration (the answer changes only when an S-bearing
	// artifact first arrives) because the probe costs ~250ms.
	//
	// Defaults to FALSE, which is the safe direction: offering a fossil
	// clone that cannot work wastes somebody's time and makes quarry
	// look wrong, where withholding one they could have used costs them
	// a copied command.
	FossilClone bool
	// Sync runs one ingest round; write ops call it so a successful
	// response means the index already reflects the write (read-your-
	// writes). nil skips (the background poll converges instead).
	Sync func() (int, error)
	// Reindex is the full wipe-and-replay (0001 §5) run IN PROCESS, so
	// an operator repairs a store while the forge serves — the offline
	// CLI needed the server stopped, which priced a one-repo repair at
	// a whole-forge outage. nil disables the op (tests, static wiring).
	Reindex func() (int, error)
	// Guard runs a write under the store's single-writer lease (0001
	// §3): it refuses when another node holds the lease or the
	// coordinator is unreachable. nil runs the write directly
	// (single-node default).
	Guard func(func() error) error
	// Code is the store's project code — the chat channel key (0001
	// §5), stable across nodes and repo renames.
	Code string
	// Indexing reports whether this repo's first ingest is still
	// running. A large store takes minutes to fold, and during that
	// window its counts read as zero — which looks broken unless the
	// surface says so. nil means "never indexing" (test wiring).
	Indexing func() bool
	// Health reports this store's ingest state for the fleet admin
	// surface. Stated as a func returning a local type rather than
	// exposing the pipeline, for the same reason Sync is: the ops layer
	// names what it needs and the composition root owns the wiring.
	// nil means "no health available" (test wiring).
	Health func() RepoHealth
	// Mirror pulls from an upstream into this store and reports whether
	// anything arrived. It must run under the same single-writer lease
	// Guard enforces — a pull WRITES artifacts, so two nodes mirroring
	// the same store concurrently is the exact race the lease exists to
	// prevent. nil means this store cannot be mirrored.
	Mirror func(url string) (bool, error)
}

// RepoHealth is one store's ingest state, as the fleet admin page needs
// it. Durations are seconds and times are RFC3339 so the shape survives
// the JSON surface unchanged.
type RepoHealth struct {
	// LastOK is when a round last succeeded, empty if none ever has.
	LastOK string `json:"lastOk"`
	// RoundSeconds is how long the most recent round took.
	RoundSeconds float64 `json:"roundSeconds"`
	// LastCount is artifacts published in the last round, Total since
	// this process started.
	LastCount int `json:"lastCount"`
	Total     int `json:"total"`
	// Fallbacks counts artifacts that would not parse and were indexed
	// as opaque content.
	Fallbacks int `json:"fallbacks"`
	// Gaps counts DAG edges pointing at a check-in the index does not
	// hold: artifacts lost from the bounded event window that a rebuild
	// has not recovered. Distinct from a failing round — this one says
	// the index is serving an incomplete history as if it were whole.
	Gaps int `json:"gaps"`
	// LastErr is the most recent round's failure, empty when healthy.
	// Failures counts CONSECUTIVE failures: 1 is a blip, 900 is wedged.
	LastErr   string `json:"lastErr"`
	LastErrAt string `json:"lastErrAt"`
	Failures  int    `json:"failures"`
	// Stalled means ingest has stopped retrying on its own schedule
	// after consecutive failures; RetryAt is the next attempt. The
	// store is intact and the cursor held — the index lags rather than
	// lies — but it stops advancing until the cause is fixed.
	Stalled bool   `json:"stalled"`
	RetryAt string `json:"retryAt,omitempty"`
}

// ChatPlane is the per-repo live channel (0001 §5). A nil Deps.Chat
// disables chat (no event plane).
type ChatPlane interface {
	Post(code string, m chat.Message) error
	History(code string, limit int) ([]chat.Message, error)
}

// NotifyEvent is one notification trigger quarry raises for the notify
// companion to route (0001 §5): quarry names the workflow and the
// recipient and hands over a payload; notify owns delivery.
type NotifyEvent struct {
	Workflow string         // notify workflow name (quarry-mention, quarry-activity)
	To       string         // subscriber id — a user, or "repo:<name>" topic
	Payload  map[string]any // repo, actor, summary, link
	TxnID    string         // idempotency key (dedups replays)
}

// NotifyPlane fires triggers best-effort. A nil Deps.Notify disables
// notifications.
type NotifyPlane interface {
	Trigger(ctx context.Context, e NotifyEvent) error
}

// WebhookPlane queues one activity event for third-party delivery
// (Principle 6). Deliberately stated in primitives rather than the
// webhook package's own type: the ops layer names what happened, and
// the composition root owns how it is delivered. A nil Deps.Hooks
// disables webhooks.
type WebhookPlane interface {
	Raise(id, repo, kind, actor, summary, link string) error
}

// mentionRE matches an @handle mention — the token notify routes to a
// subscriber. The @ must start a word (not follow a name char), so an
// email address's @ is not a mention.
var mentionRE = regexp.MustCompile(`(^|[^a-zA-Z0-9._-])@([a-zA-Z0-9][a-zA-Z0-9_-]{0,63})`)

// mentions returns the distinct @handles named in text, without the @.
func mentions(text string) []string {
	seen := map[string]bool{}
	var out []string
	for _, m := range mentionRE.FindAllStringSubmatch(text, -1) {
		if !seen[m[2]] {
			seen[m[2]] = true
			out = append(out, m[2])
		}
	}
	return out
}

// fireNotify raises an event best-effort: never blocks the op, never
// fails it. A disabled plane (nil) is a no-op.
//
// An ACTIVITY event is also the webhook surface's event — the same
// thing happened, and Principle 6 asks that a third party be able to
// see it without speaking NATS. Mentions are deliberately not
// forwarded: they are addressed to one person, not to the repository.
// Queueing is a publish, so it does not slow the op down; the delivery
// worker does the HTTP.
func (d Deps) fireNotify(e NotifyEvent) {
	if d.Hooks != nil && e.Workflow == "quarry-activity" {
		repo, _ := e.Payload["repo"].(string)
		actor, _ := e.Payload["actor"].(string)
		summary, _ := e.Payload["summary"].(string)
		link, _ := e.Payload["link"].(string)
		if err := d.Hooks.Raise(e.TxnID, repo, kindOf(e.TxnID), actor, summary, link); err != nil {
			log.Printf("webhook: queue %s: %v", e.TxnID, err)
		}
	}
	if d.Notify == nil {
		return
	}
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		if err := d.Notify.Trigger(ctx, e); err != nil {
			log.Printf("notify: %s: %v", e.Workflow, err)
		}
	}()
}

// WriteTag records a tag artifact on a check-in through the lease
// holder — a verdict is a write like any other, and two nodes must not
// both record one. The index picks it up on the next ingest round.
func (r *Repo) WriteTag(name, checkin, value string) error {
	if err := r.write(func() error {
		return r.Zit.TagAdd(name, checkin, value, false)
	}); err != nil {
		return err
	}
	return r.synced()
}

// synced runs the repo's ingest round after a successful write.
//
// A stalled pipeline is not a failed WRITE: the artifact is already
// durable in the store, and failing the request now would report loss
// where there is only lag. The reader may not see their change until
// the stall clears, which the fleet page says out loud.
func (r *Repo) synced() error {
	if r.Sync == nil {
		return nil
	}
	_, err := r.Sync()
	if errors.Is(err, ingest.ErrStalled) {
		return nil
	}
	return err
}

// write runs fn under the repo's write lease (or directly if unleased).
func (r *Repo) write(fn func() error) error {
	if r.Guard == nil {
		return fn()
	}
	return r.Guard(fn)
}

// hasControl reports whether s carries an ASCII control byte (below
// 0x20, or DEL) — such bytes have no place in a wiki name or ticket id
// and would otherwise reach the zit subprocess and fault.
func hasControl(s string) bool {
	for i := 0; i < len(s); i++ {
		if s[i] < 0x20 || s[i] == 0x7f {
			return true
		}
	}
	return false
}

// author resolves the recorded author: the authenticated user, or
// "anonymous" on an ungated repo.
func author(ctx context.Context) string {
	if u := ops.User(ctx); u != "" {
		return u
	}
	return "anonymous"
}

// Deps carries what op implementations close over. Repos may be empty
// (`quarry spec` runs storeless); ops answer unknown repos with
// not-found, and the spec stays complete either way.
type Deps struct {
	Version string
	Repos   *Repos
	Chat    ChatPlane
	Notify  NotifyPlane
	Hooks   WebhookPlane
	// CI is the run record; nil when no engine is configured, which is
	// how a forge without CI simply has no CI pages.
	CI CIRuns
	// CanRead gates reading a repository at all. nil means every
	// repository is public, which is v1's behaviour and what tests want.
	//
	// It takes a context because the answer depends on WHO is asking,
	// and the identity rides there. Returning false must make the
	// repository indistinguishable from one that does not exist — see
	// Deps.repoFor.
	CanRead func(ctx context.Context, repo string) bool
	// Accounts is forge identity: who exists and what they may do. nil
	// disables the account ops, which is how a forge with no accounts
	// configured simply has no account surface.
	Accounts AccountStore
	// Private reports which repositories are private, for the listing's
	// badge. Separate from CanRead, which answers a different question:
	// this is "is it marked private", that is "may you see it".
	Private func() map[string]bool
	// MirrorFreshness reports each mirrored repository's last successful
	// pull; absent means not mirrored.
	MirrorFreshness func() map[string]string
	// Deletable reports which repositories this node created and may
	// therefore destroy.
	Deletable func(repo string) bool
	// Descriptions supplies each repository's one-line summary for the
	// listing; nil simply renders none.
	Descriptions func() map[string]string
	// Registry adds and removes served repositories at runtime; nil
	// leaves the fleet fixed at whatever --repo named, which is the old
	// behaviour and what tests want.
	Registry RepoRegistry
	// Bulk bounds operations expensive in MEMORY as well as time.
	// archive buffers up to 256 MiB per request on an ANONYMOUS route,
	// so ten concurrent requests would be 2.5 GiB resident and ten
	// subprocesses. That is latent rather than live today — archive 503s
	// on zit#97, so nothing is buffered yet — which is exactly why the
	// bound belongs here now: the endpoint should be born bounded rather
	// than acquire a limit after someone finds it.
	//
	// nil runs unbounded, which is the old behaviour and suits tests.
	Bulk *work.Lane
	// Archives caches built archives on disk. An archive costs seconds
	// of compression on an anonymous route and the bytes are
	// reproducible, so the same check-in must not be rebuilt per
	// reader. nil builds every time, which is correct but slow and is
	// what tests use.
	Archives *archive.Cache
	// Mirrors is the upstream-tracking store; nil disables the mirror
	// ops entirely, which is how a forge that mirrors nothing simply has
	// no mirror surface.
	Mirrors MirrorStore
	// Errors is the server's error journal (errlog.Default in the real
	// wiring); nil reports no errors, which suits tests.
	Errors *errlog.Ring
}

// AccountStore is forge identity. Stated in this package's own types so
// the ops layer does not depend on the OLTP package's, matching how
// CIRuns and MirrorStore are stated.
//
// The password VERIFIER never appears here in any direction. Passwords
// go in; nothing that could verify one comes back.
type AccountStore interface {
	Accounts() ([]Account, error)
	AddAccount(name, password string, admin bool) error
	SetPassword(name, password string) error
	DeleteAccount(name string) error
	Grant(account, repo, role string) error
	Revoke(account, repo string) error
	GrantsFor(account string) (map[string]string, error)
}

// Account is one login as the admin surface sees it: never the verifier.
type Account struct {
	Name    string `json:"name"`
	Admin   bool   `json:"admin"`
	Created string `json:"created"`
}

// RepoRegistry is the seam for changing which repositories are served.
// Stated here rather than as the composition root's concrete type for
// the same reason MirrorStore is: the op layer names what it needs, and
// main owns how a repository is actually opened and torn down.
//
// Add must be atomic in effect — a repository is serving and persisted,
// or neither. A registration that starts serving but is not recorded
// disappears on the next restart, which is the worst of both.
type RepoRegistry interface {
	// Add returns where the repository was placed, which the caller may
	// have left to the server.
	Add(spec RepoSpec) (path string, err error)
	Remove(name string) error
	Delete(name, confirm string) error
	Update(name, description, visibility string) error
}

// RepoSpec says which repository to serve and where it comes from.
// Exactly one origin applies: an existing store at Path, a new empty one
// (Create), or a clone of Clone. A struct rather than positional
// arguments because this list has grown twice already.
type RepoSpec struct {
	Name string
	// Path may be empty, in which case the node puts the repository
	// under its repositories root. Nobody picks a filesystem path when
	// they create a repository on a forge, and requiring one made the
	// API unusable by anyone who was not also the server's operator.
	Path string
	// Description is the one line the repository list shows.
	Description string
	// Visibility is "public" or "private"; empty means public.
	Visibility string
	// Create runs `zit init` at Path first — the "host my project here"
	// case, which quarry could not do at all.
	Create bool
	// Readme, License and Ignore seed the first check-in. Only meaningful
	// with Create: a cloned repository already has a history.
	Readme  bool
	License string
	Ignore  bool
	// Author records the check-in's user.
	Author string
	// Clone copies the repository served at this URL into Path. quarry
	// also records a mirror for it unless Mirror is false: adopting a
	// remote repository and then not tracking it is almost never what
	// anyone meant.
	Clone  string
	Mirror bool
	// MirrorEvery is the pull interval in seconds; zero takes the
	// default.
	MirrorEvery int
}

// MirrorStore is the mirror configuration seam. Stated in this package's
// own types so the ops layer does not depend on the OLTP package's,
// matching how CIRuns is stated.
type MirrorStore interface {
	Mirrors() ([]Mirror, error)
	MirrorFor(repo string) (Mirror, bool, error)
	SetMirror(repo, url string, everySeconds int) error
	DeleteMirror(repo string) error
	// RecordMirrorRun stamps an attempt; err nil means it succeeded.
	RecordMirrorRun(repo string, err error) error
}

// Mirror is one store's upstream configuration and recent history.
type Mirror struct {
	Repo    string `json:"repo"`
	URL     string `json:"url"`
	Every   int    `json:"every"` // seconds
	Enabled bool   `json:"enabled"`
	// LastAt is the last ATTEMPT and LastOK the last success, kept apart
	// so a store that has been failing for a week still reports when it
	// was last actually current.
	LastAt   string `json:"lastAt"`
	LastOK   string `json:"lastOk"`
	LastErr  string `json:"lastErr"`
	Failures int    `json:"failures"`
}

// kindOf recovers the event kind from an activity idempotency key,
// which is built as "activity:<kind>:<hash>". Keeping one key format
// means the webhook kind and the dedup key cannot disagree.
func kindOf(txnID string) string {
	rest, ok := strings.CutPrefix(txnID, "activity:")
	if !ok {
		return "activity"
	}
	kind, _, _ := strings.Cut(rest, ":")
	return kind
}

// PullMirror runs a single mirror: pull, stamp the attempt, and re-ingest
// only if artifacts actually arrived. It is shared by the mirror.run op
// and the background scheduler (via the composition root) so a manual pull and a scheduled one
// cannot drift in behaviour.
//
// The attempt is recorded BEFORE the ingest round, and the ingest error
// is deliberately not folded into the mirror's error: a pull that
// succeeded and an index that then failed to fold are different
// failures with different fixes, and reporting the second as the first
// sends an operator to look at the network.
func (d Deps) PullMirror(m Mirror) (bool, error) {
	repo, err := d.repo(m.Repo)
	if err != nil {
		return false, err
	}
	if repo.Mirror == nil {
		return false, fmt.Errorf("repository %q cannot be mirrored: %w", m.Repo, ops.ErrUnavailable)
	}
	changed, pullErr := repo.Mirror(m.URL)
	if err := d.Mirrors.RecordMirrorRun(m.Repo, pullErr); err != nil {
		log.Printf("mirror: %s: recording run: %v", m.Repo, err)
	}
	if pullErr != nil {
		return false, pullErr
	}
	if changed {
		if err := repo.synced(); err != nil {
			return true, fmt.Errorf("pulled, but indexing failed: %w", err)
		}
	}
	return changed, nil
}

// visibilityOf turns the API's boolean into the stored word.
func visibilityOf(private bool) string {
	if private {
		return "private"
	}
	return "public"
}

func errNoAccounts() error {
	return fmt.Errorf("this node has no account store: %w", ops.ErrUnavailable)
}

// scrub removes a secret from an error's text before it travels.
//
// The store's errors quote what they were given, and for these two ops
// what they were given is a password. An error is the one value that
// reliably reaches a log, an HTTP body and a terminal, so it is the one
// place a secret must not be.
func scrub(err error, secret string) string {
	msg := err.Error()
	if secret == "" {
		return msg
	}
	return strings.ReplaceAll(msg, secret, "[redacted]")
}

// resolveVersion turns a hash PREFIX into a full check-in hash, and
// passes every other reference through untouched.
//
// It deliberately does not resolve names. 0001 §2 makes zit the
// authority on them, and an earlier version of this function ignored
// that: it fell back to the index's branch table, so "trunk" resolved to
// whatever check-in sat on a branch literally named trunk. On tk — which
// renamed its trunk to main — that served a README from 2020 while zit
// resolved the same word to the current tip on main. Names are aliases,
// defaults and tags with rules that live in zit; a second implementation
// here is a slower way to be wrong.
//
// The consequence is that a symbolic version is not cacheable, because
// what it names can change under it. Making file links carry the
// RESOLVED hash is how that gets fixed, and that resolution has to come
// from zit.
func (d Deps) resolveVersion(repo *Repo, ref string) string {
	if ref == "" {
		return "trunk"
	}
	if repo.Index == nil {
		return ref
	}
	if e, _, ok, err := repo.Index.CheckinByPrefix(ref); err == nil && ok {
		return e.Hash
	}
	return ref
}

// repo resolves one registered store without a visibility check. Use it
// only where the caller has already been gated, or where no reader is
// involved (the mirror scheduler, CI).
func (d Deps) repo(name string) (*Repo, error) {
	if r, ok := d.Repos.Get(name); ok {
		return r, nil
	}
	return nil, fmt.Errorf("no repository %q: %w", name, ops.ErrNotFound)
}

// repoFor resolves a store for a READER, and is what every request-path
// op should use.
//
// A repository the caller may not see reports NOT FOUND, never
// forbidden. A 403 confirms the repository exists, which is precisely
// what a private repository must not confirm: "no such repository" and
// "you may not see this repository" have to be one answer, or the
// existence of a name leaks to anyone who can spell it.
func (d Deps) repoFor(ctx context.Context, name string) (*Repo, error) {
	r, err := d.repo(name)
	if err != nil {
		return nil, err
	}
	if d.CanRead != nil && !d.CanRead(ctx, name) {
		return nil, fmt.Errorf("no repository %q: %w", name, ops.ErrNotFound)
	}
	return r, nil
}

type versionIn struct{}

type versionOut struct {
	Version string `json:"version"`
}

// repoListIn filters and orders the listing. A forge's front page is a
// listing, and an unfiltered, unsorted, unbounded one is the shape that
// made the timeline unusable — render everything, sort nothing.
type repoListIn struct {
	// Q filters by name substring, case-insensitive.
	Q string `json:"q" validate:"max=64"`
	// Sort is active (most recent event first), name, or size.
	Sort string `json:"sort" validate:"oneof=active|name|size"`
	// Limit caps the page; 0 means no limit, which is what internal
	// callers and a small fleet want.
	Limit int `json:"limit" validate:"min=0,max=500"`
}

type repoGetIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

type repoInfo struct {
	Name string `json:"name"`
	// Contributors is how many distinct people have authored anything
	// here, and MirroredAt when this node last successfully pulled from
	// upstream — empty when the repository is not mirrored.
	//
	// Mirror freshness is on the listing because mirroring created an
	// ambiguity: a repository quiet upstream and a mirror that has been
	// failing for a week look identical if all a reader sees is "last
	// activity".
	Contributors int    `json:"contributors"`
	MirroredAt   string `json:"mirroredAt"`
	// Deletable is whether this node created the store and may destroy
	// it, so a surface can offer deletion only where it would work.
	Deletable bool `json:"deletable"`
	// FossilClone reports that stock fossil can clone this store — it
	// holds no sub-manifest artifact. zit and fossil share the sync
	// protocol, so a repository that never emitted one is readable by
	// either tool, and a reader who already has fossil should be told
	// so rather than made to install something.
	FossilClone bool `json:"fossilClone"`
	// LastActive is the newest event's date, empty when the repository
	// has none. It is what "sort by activity" orders on and what a
	// reader actually scans a forge listing for.
	LastActive  string `json:"lastActive"`
	Description string `json:"description"`
	Private     bool   `json:"private"`
	Checkins    int    `json:"checkins"`
	TipDate     string `json:"tipDate"`
	TipComment  string `json:"tipComment"`
	// Indexing is true while the first ingest is still folding, so a
	// zero count reads as "catching up", not "empty".
	Indexing bool `json:"indexing"`
}

type wikiSaveIn struct {
	Repo     string `json:"repo" validate:"required,max=64"`
	Name     string `json:"name" validate:"required,max=100"`
	Body     string `json:"body" validate:"required,max=1048576"`
	Mimetype string `json:"mimetype" validate:"max=64"`
}

type wikiSaveOut struct {
	Hash string `json:"hash"`
}

// DefaultTicketVocabulary is fossil's default ticket vocabulary
// (tktsetup.c), one ordered word list per field. LIST ORDER IS SORT
// ORDER — there is no separate weight anywhere; a word earlier in its
// list outranks a later one, which is also how fossil's own choice
// lists work.
//
// A repository overrides any of these lists with .quarry/tickets.json
// at the tip of trunk (see the ticket.config op). Exported so the web
// layer renders something sensible even when the op cannot be asked.
var DefaultTicketVocabulary = map[string][]string{
	"status":     {"Open", "Verified", "Review", "Deferred", "Fixed", "Tested", "Closed"},
	"type":       {"Code_Defect", "Build_Problem", "Documentation", "Feature_Request", "Incident"},
	"severity":   {"Critical", "Severe", "Important", "Minor", "Cosmetic"},
	"priority":   {"Immediate", "High", "Medium", "Low", "Zero"},
	"subsystem":  {},
	"resolution": {"Open", "Fixed", "Rejected", "Workaround", "Unable_To_Reproduce", "Works_As_Designed", "External_Bug", "Not_A_Bug", "Duplicate", "Overcome_By_Events", "Drive_By_Patch", "Misconfiguration"},
}

// ticketConfigPath is where a repository keeps its forge configuration:
// tracked files in the tree, like .github but for quarry. Versioned
// like everything else — editing config is a commit, clones carry it,
// and a wipe-and-reingest rebuilds it, so disposability holds.
const ticketConfigPath = ".quarry/tickets.json"

// repoConfigPath is .quarry's general repo configuration — the second
// tenant beside tickets.json: homepage, topics, and the docs tab list.
const repoConfigPath = ".quarry/repo.json"

type aboutIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Doc selects a tab to render; empty renders the first tab. A path
	// outside the tab set is refused — the tab strip is curated, and
	// this op must not become a render-any-file endpoint.
	Doc string `json:"doc" validate:"max=512"`
}

// aboutDoc is one tab of the landing page's document panel.
type aboutDoc struct {
	Path  string `json:"path"`
	Label string `json:"label"`
}

type aboutOut struct {
	// The selected tab, rendered. DocPath is empty when the repo has no
	// documents at all.
	DocPath     string   `json:"docPath,omitempty"`
	DocLabel    string   `json:"docLabel,omitempty"`
	DocHTML     string   `json:"docHtml,omitempty"`
	Description string   `json:"description,omitempty"`
	Homepage    string   `json:"homepage,omitempty"`
	Topics      []string `json:"topics,omitempty"`
	// Docs is the tab set: .quarry/repo.json's docs list when present
	// (curation includes removal, so it REPLACES the convention), else
	// the root-level marker files in conventional order.
	Docs []aboutDoc `json:"docs,omitempty"`
	// LatestTag is the newest release tag; empty when the repo has none.
	LatestTag     string `json:"latestTag,omitempty"`
	LatestTagDate string `json:"latestTagDate,omitempty"`
	Contributors  int    `json:"contributors"`
	Problem       string `json:"problem,omitempty"`
}

// parseRepoConfig reads .quarry/repo.json. Unknown fields are ignored
// (newer file, older quarry); wrong shapes are an error so a typo does
// not silently become "no config".
func parseRepoConfig(raw []byte) (homepage string, topics []string, docs []aboutDoc, err error) {
	var file struct {
		Homepage string     `json:"homepage"`
		Topics   []string   `json:"topics"`
		Docs     []aboutDoc `json:"docs"`
	}
	if err := json.Unmarshal(raw, &file); err != nil {
		return "", nil, nil, fmt.Errorf("not a config object: %w", err)
	}
	if file.Homepage != "" {
		u, uerr := url.Parse(file.Homepage)
		if uerr != nil || (u.Scheme != "http" && u.Scheme != "https") {
			return "", nil, nil, fmt.Errorf("homepage %q: want an http(s) URL", file.Homepage)
		}
	}
	if len(file.Topics) > 20 {
		return "", nil, nil, fmt.Errorf("%d topics; the limit is 20", len(file.Topics))
	}
	for i, t := range file.Topics {
		file.Topics[i] = strings.TrimSpace(t)
		if len(file.Topics[i]) > 40 {
			return "", nil, nil, fmt.Errorf("topic %q is longer than 40 bytes", t)
		}
	}
	if len(file.Docs) > 12 {
		return "", nil, nil, fmt.Errorf("%d docs tabs; the limit is 12", len(file.Docs))
	}
	for i, d := range file.Docs {
		if d.Path == "" {
			return "", nil, nil, fmt.Errorf("docs[%d]: a tab needs a path", i)
		}
		if file.Docs[i].Label == "" {
			base := path.Base(d.Path)
			file.Docs[i].Label = strings.TrimSuffix(base, path.Ext(base))
		}
	}
	return file.Homepage, file.Topics, file.Docs, nil
}

// markerDocs finds the conventional root-level documents, in the order
// GitHub taught everyone to expect them. Case-insensitive on the stem;
// README always leads because it is the default tab.
func markerDocs(files []zitcli.LsEntry) []aboutDoc {
	want := []struct{ stem, label string }{
		{"readme", "README"},
		{"contributing", "Contributing"},
		{"license", "License"},
		{"copying", "License"},
		{"security", "Security"},
	}
	var out []aboutDoc
	seen := map[string]bool{}
	for _, w := range want {
		if seen[w.label] {
			continue
		}
		for _, f := range files {
			if strings.Contains(f.Name, "/") {
				continue // convention discovers at the ROOT only
			}
			stem := strings.ToLower(strings.TrimSuffix(f.Name, path.Ext(f.Name)))
			if stem == w.stem {
				out = append(out, aboutDoc{Path: f.Name, Label: w.label})
				seen[w.label] = true
				break
			}
		}
	}
	return out
}

type meFeedIn struct{}

// feedEvent is one row of the signed-in dashboard's feeds: a timeline
// entry that knows which repository it came from.
type feedEvent struct {
	Repo    string `json:"repo"`
	Kind    string `json:"kind"`
	Ref     string `json:"ref"`
	Hash    string `json:"hash"`
	Date    string `json:"date"`
	User    string `json:"user"`
	Comment string `json:"comment"`
}

type dashRepo struct {
	Name         string `json:"name"`
	Description  string `json:"description,omitempty"`
	LastActivity string `json:"lastActivity,omitempty"`
}

type meFeedOut struct {
	User  string     `json:"user"`
	Repos []dashRepo `json:"repos"`
	// Feed is the merged newest-first activity across the repos above.
	Feed []feedEvent `json:"feed"`
	// Mine is the caller's own recorded work. Forge logins and artifact
	// author names are different namespaces; v1 matches them by string
	// equality and shows nothing when they differ — honest, and the
	// mapping is a small follow-up decision.
	Mine []feedEvent `json:"mine"`
}

type reindexIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

type reindexOut struct {
	Artifacts int `json:"artifacts"`
}

type ticketConfigIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

// vocabField is one field's ordered word list. A list rather than a
// map: the ops spec has no map kind, and a stable field order is what
// a form iterating it wants anyway.
type vocabField struct {
	Field string   `json:"field"`
	Words []string `json:"words"`
}

// vocabFieldOrder is the order fields are listed in: the sortable axes
// first, then the words a form offers but a board does not group by.
var vocabFieldOrder = []string{"status", "priority", "severity", "type", "subsystem", "resolution"}

type ticketConfigOut struct {
	// Vocab is the complete merged vocabulary: the file's lists over
	// the defaults. Always complete — a consumer never needs to know
	// which side a list came from to use it.
	Vocab []vocabField `json:"vocab"`
	// Source says where the words came from: "default", or the config
	// path when the file contributed.
	Source string `json:"source"`
	// Problem is set when a config file exists but could not be used —
	// the page renders defaults rather than an error, and this is how
	// an author finds out why their file did nothing.
	Problem string `json:"problem,omitempty"`
}

// parseTicketVocab reads a .quarry/tickets.json: an object of field
// name to ordered word list. Unknown fields are ignored (a newer file
// against an older quarry should not break), unknown SHAPES are an
// error (a typo should not silently become "defaults"). Words are
// trimmed; empties dropped; oversized lists refused rather than
// truncated, because a silently shortened vocabulary would reorder the
// tail of every sorted view.
func parseTicketVocab(raw []byte) (map[string][]string, error) {
	var file map[string][]string
	if err := json.Unmarshal(raw, &file); err != nil {
		return nil, fmt.Errorf("not an object of field to word list: %w", err)
	}
	out := map[string][]string{}
	for field, words := range file {
		if _, known := DefaultTicketVocabulary[field]; !known {
			continue
		}
		if len(words) > 50 {
			return nil, fmt.Errorf("field %q: %d words; the limit is 50", field, len(words))
		}
		var clean []string
		for _, w := range words {
			w = strings.TrimSpace(w)
			if w == "" {
				continue
			}
			if len(w) > 80 {
				return nil, fmt.Errorf("field %q: word %.20q… is longer than 80 bytes", field, w)
			}
			clean = append(clean, w)
		}
		out[field] = clean
	}
	return out, nil
}

type ticketNewIn struct {
	Repo      string `json:"repo" validate:"required,max=64"`
	Title     string `json:"title" validate:"required,max=200"`
	Status    string `json:"status" validate:"max=40"`
	Comment   string `json:"comment" validate:"max=65536"`
	Type      string `json:"type" validate:"max=40"`
	Severity  string `json:"severity" validate:"max=40"`
	Priority  string `json:"priority" validate:"max=40"`
	Subsystem string `json:"subsystem" validate:"max=80"`
}

type ticketNewOut struct {
	ID   string `json:"id"`
	Hash string `json:"hash"`
}

type ticketSetIn struct {
	Repo       string `json:"repo" validate:"required,max=64"`
	ID         string `json:"id" validate:"required,min=4,max=40"`
	Title      string `json:"title" validate:"max=200"`
	Status     string `json:"status" validate:"max=40"`
	Resolution string `json:"resolution" validate:"max=40"`
	Comment    string `json:"comment" validate:"max=65536"`
	// The rest of fossil's default triage vocabulary. Values are not
	// checked against a list: a tracker's vocabulary is its own, and
	// refusing a word this build has not heard of would make quarry
	// unable to edit trackers it can already read.
	Type      string `json:"type" validate:"max=40"`
	Severity  string `json:"severity" validate:"max=40"`
	Priority  string `json:"priority" validate:"max=40"`
	Subsystem string `json:"subsystem" validate:"max=80"`
}

type ticketSetOut struct {
	Hash string `json:"hash"`
}

type docsIn struct{}

type docsOut struct {
	Markdown string `json:"markdown"`
	HTML     string `json:"html"`
}

type repoListOut struct {
	Repos []repoInfo `json:"repos"`
	// Count is how many this page holds; Total how many matched the
	// filter before the limit. Both, because "23 of 140" is the thing a
	// reader needs and one number cannot say it.
	Count int `json:"count"`
	Total int `json:"total"`
	// Served is how many repositories the caller may read BEFORE the
	// filter. Total alone made a filtered listing say "1 of 1", which
	// is true and tells a reader nothing: what they want to know when
	// filtering is how many of the fleet matched.
	Served int `json:"served"`
}

type fleetStatusIn struct{}

// fleetRepo is one row of the fleet admin surface: identity, size, and
// ingest health together. Health is admin-only because LastErr carries
// operational detail (store paths, NATS subjects) that the anonymous
// repo.list has no business leaking.
type fleetRepo struct {
	Name     string     `json:"name"`
	Code     string     `json:"code"`
	Checkins int        `json:"checkins"`
	Indexing bool       `json:"indexing"`
	Health   RepoHealth `json:"health"`
}

type fleetStatusOut struct {
	Repos []fleetRepo `json:"repos"`
	Count int         `json:"count"`
	// Unhealthy counts stores whose last round failed — the number an
	// operator actually scans for.
	Unhealthy int `json:"unhealthy"`
	// Errors is the server-error journal: what has answered "internal
	// error" to a reader, deduplicated, newest first. ErrorTotal counts
	// occurrences; the rows carry per-signature counts.
	Errors     []errlog.Entry `json:"errors,omitempty"`
	ErrorTotal int            `json:"errorTotal"`
}

// fleetSyncIn names one repository, or all of them when Repo is empty.
type fleetSyncIn struct {
	Repo string `json:"repo" validate:"max=64"`
}

type fleetSyncResult struct {
	Repo      string `json:"repo"`
	Artifacts int    `json:"artifacts"`
	Err       string `json:"err,omitempty"`
}

type repoCountsIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

// repoCountsOut is the repository nav's content census. It is its own op
// rather than part of repo.list because it is per-repository and read on
// every page render; the index memoizes it per fold.
type repoCountsOut struct {
	Checkins     int `json:"checkins"`
	WikiPages    int `json:"wikiPages"`
	Tickets      int `json:"tickets"`
	ForumThreads int `json:"forumThreads"`
	Technotes    int `json:"technotes"`
	Contributors int `json:"contributors"`
	// Tags is release tags, memoized with the rest: the ref switcher
	// shows a page of them and the stats strip needs the true total,
	// which is not the same number.
	Tags int `json:"tags"`
}

type accountListIn struct{}

// accountGrant is one repository grant, for the listing.
type accountGrant struct {
	Repo string `json:"repo"`
	Role string `json:"role"`
}

type accountRow struct {
	Account
	Grants []accountGrant `json:"grants"`
}

type accountListOut struct {
	Accounts []accountRow `json:"accounts"`
	Count    int          `json:"count"`
}

// accountAddIn carries a plaintext password, which makes this one of the
// two ops that must never be logged with its input. The ops layer's
// error path logs context; these must not hand it one.
type accountAddIn struct {
	Name     string `json:"name" validate:"required,max=64"`
	Password string `json:"password" validate:"required,max=1024"`
	Admin    bool   `json:"admin"`
}

type accountAddOut struct {
	Name  string `json:"name"`
	Admin bool   `json:"admin"`
}

type accountPasswdIn struct {
	Name     string `json:"name" validate:"required,max=64"`
	Password string `json:"password" validate:"required,max=1024"`
}

type accountDeleteIn struct {
	Name string `json:"name" validate:"required,max=64"`
}

type accountNameOut struct {
	Name string `json:"name"`
}

type accountGrantIn struct {
	Name string `json:"name" validate:"required,max=64"`
	Repo string `json:"repo" validate:"required,max=64"`
	Role string `json:"role" validate:"required,oneof=read|write|admin"`
}

type accountGrantOut struct {
	Name string `json:"name"`
	Repo string `json:"repo"`
	Role string `json:"role"`
}

type accountRevokeIn struct {
	Name string `json:"name" validate:"required,max=64"`
	Repo string `json:"repo" validate:"required,max=64"`
}

type repoAddIn struct {
	Name string `json:"name" validate:"required,max=64"`
	// Path is optional: omitted, the server places the repository under
	// its repositories root.
	Path        string `json:"path" validate:"max=4096"`
	Description string `json:"description" validate:"max=300"`
	// Private creates the repository unreadable to anyone without a role
	// on it. Absent means public — the safe default is the one that does
	// not surprise, and hiding something by accident is recoverable
	// while publishing it by accident is not... but a forge whose repos
	// silently vanish is worse, so public it is, and taking a repository
	// private stays the deliberate act.
	Private bool `json:"private"`
	// Create makes an empty repository at path; Clone copies one from a
	// URL. Both are optional and mutually exclusive; with neither, path
	// must already hold a repository.
	Create bool   `json:"create"`
	Clone  string `json:"clone" validate:"max=2048"`
	// Readme seeds a first check-in, so the repository has a born trunk
	// rather than an unborn one with nothing to show. License is "mit",
	// "bsd-2" or empty; Ignore adds a starter .zitignore.
	Readme  bool   `json:"readme"`
	License string `json:"license" validate:"max=16"`
	Ignore  bool   `json:"ignore"`
	// NoMirror suppresses the mirror a clone would otherwise record.
	// Negative because tracking what you just adopted is the default.
	NoMirror    bool `json:"noMirror"`
	MirrorEvery int  `json:"mirrorEvery" validate:"min=0,max=604800"`
}

type repoAddOut struct {
	Name string `json:"name"`
	// Path is where the server actually placed it, which the caller may
	// not have chosen.
	Path string `json:"path"`
	// Mirrored reports whether a mirror was recorded, so the caller does
	// not have to infer it from the flags it sent.
	Mirrored bool `json:"mirrored"`
}

// repoUpdateIn is the repository's settings, applied WHOLE. A settings
// form submits every field it manages, so partial-update semantics
// would only add a way for the two to disagree about what "absent"
// means.
type repoUpdateIn struct {
	Name        string `json:"name" validate:"required,max=64"`
	Description string `json:"description" validate:"max=300"`
	Private     bool   `json:"private"`
}

type repoUpdateOut struct {
	Name        string `json:"name"`
	Description string `json:"description"`
	Private     bool   `json:"private"`
}

// repoDeleteIn destroys a repository. Confirm must equal Name: the
// typed-name pattern is used because a yes/no prompt is answered
// reflexively and this operation has no undo.
type repoDeleteIn struct {
	Name    string `json:"name" validate:"required,max=64"`
	Confirm string `json:"confirm" validate:"required,max=64"`
}

type repoRemoveIn struct {
	Name string `json:"name" validate:"required,max=64"`
}

type repoRemoveOut struct {
	Name string `json:"name"`
}

type mirrorListIn struct{}

type mirrorListOut struct {
	Mirrors []Mirror `json:"mirrors"`
	Count   int      `json:"count"`
}

type mirrorSetIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	URL  string `json:"url" validate:"required,max=2048"`
	// Every is the pull interval in seconds. The store floors it — these
	// upstreams are other people's servers.
	Every int `json:"every" validate:"min=0,max=604800,default=3600"`
}

type mirrorSetOut struct {
	Mirror Mirror `json:"mirror"`
}

type mirrorDeleteIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

type mirrorDeleteOut struct {
	Repo string `json:"repo"`
}

type mirrorRunIn struct {
	// Repo names one store, or every configured mirror when empty.
	Repo string `json:"repo" validate:"max=64"`
}

type mirrorRunResult struct {
	Repo string `json:"repo"`
	// Changed reports whether the pull actually brought anything in.
	// Almost every scheduled pull is a no-op, so this is what separates
	// signal from noise in the log and on the page.
	Changed bool   `json:"changed"`
	Err     string `json:"err,omitempty"`
}

type mirrorRunOut struct {
	Results []mirrorRunResult `json:"results"`
	Failed  int               `json:"failed"`
	Changed int               `json:"changed"`
}

type fleetSyncOut struct {
	Results []fleetSyncResult `json:"results"`
	// Failed counts results carrying an error. A bulk sync reports every
	// store's outcome rather than aborting on the first failure: with
	// eleven repositories, "one of them is broken" must not hide the
	// other ten's state.
	Failed int `json:"failed"`
}

type timelineIn struct {
	Repo   string `json:"repo" validate:"required,max=64"`
	Limit  int    `json:"limit" validate:"min=0,max=200,default=50"`
	Before string `json:"before" validate:"max=96"`
	Branch string `json:"branch" validate:"max=64"`
	Y      string `json:"y" validate:"oneof=all|ci|w|t|e|f"`
}

type timelineOut struct {
	Entries []index.TimelineEntry `json:"entries"`
	Count   int                   `json:"count"`
	// Next is the `before` cursor for the following (older) page;
	// empty when this page is the last.
	Next string `json:"next"`
}

type wikiListIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

type wikiListOut struct {
	Pages []index.WikiTitle `json:"pages"`
	Count int               `json:"count"`
}

type wikiGetIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Name string `json:"name" validate:"required,max=100"`
	Rev  string `json:"rev" validate:"max=64"` // artifact hash prefix; empty = latest
}

type wikiGetOut struct {
	Hash     string `json:"hash"`
	Title    string `json:"title"`
	Date     string `json:"date"`
	User     string `json:"user"`
	Mimetype string `json:"mimetype"`
	Source   string `json:"source"`
	HTML     string `json:"html"`
	Deleted  bool   `json:"deleted,omitempty"`
}

type wikiHistoryIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Name string `json:"name" validate:"required,max=100"`
}

type technoteListIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

type technoteListOut struct {
	Notes []index.Technote `json:"notes"`
	Count int              `json:"count"`
}

type technoteGetIn struct {
	Repo  string `json:"repo" validate:"required,max=64"`
	Event string `json:"event" validate:"required,max=64"`
}

type technoteGetOut struct {
	Event   string `json:"event"`
	Date    string `json:"date"`
	User    string `json:"user"`
	Comment string `json:"comment"` // C card: the title, for the edit form
	Body    string `json:"body"`    // raw source, for the edit form
	HTML    string `json:"html"`
}

type forumListIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

type forumListOut struct {
	Threads []index.ForumThread `json:"threads"`
	Count   int                 `json:"count"`
}

type forumGetIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Root string `json:"root" validate:"required,min=4,max=64"`
}

type forumRendered struct {
	Hash   string `json:"hash"`
	Parent string `json:"parent"`
	Date   string `json:"date"`
	User   string `json:"user"`
	HTML   string `json:"html"`
}

type forumGetOut struct {
	Root  string          `json:"root"`
	Title string          `json:"title"`
	Posts []forumRendered `json:"posts"`
}

type forumPostIn struct {
	Repo  string `json:"repo" validate:"required,max=64"`
	Title string `json:"title" validate:"required,max=200"`
	Body  string `json:"body" validate:"required,max=1048576"`
	// Mimetype is the N card. Empty omits it, which Fossil reads as
	// text/x-fossil-wiki — the same dispatch the read side applies.
	Mimetype string `json:"mimetype" validate:"max=64"`
}

type forumPostOut struct {
	Hash string `json:"hash"`
}

type forumReplyIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Root names the thread being answered — the same path parameter
	// forum.get takes, so the two share one consistent template.
	Root     string `json:"root" validate:"required,min=4,max=64"`
	Body     string `json:"body" validate:"required,max=1048576"`
	Mimetype string `json:"mimetype" validate:"max=64"`
}

type forumReplyOut struct {
	Hash string `json:"hash"`
}

type technoteAddIn struct {
	Repo     string `json:"repo" validate:"required,max=64"`
	Body     string `json:"body" validate:"required,max=1048576"`
	Mimetype string `json:"mimetype" validate:"max=64"`
	// Comment is the C card, which a timeline renders as the note's
	// title. It is write-once at creation: zit refuses to attach the
	// card later, so a note saved without one shows its event id
	// forever.
	Comment string `json:"comment" validate:"max=200"`
	// Event reuses an existing technote's id to record an edit; empty
	// mints a new technote.
	Event string `json:"event" validate:"max=64"`
}

type technoteAddOut struct {
	Event string `json:"event"`
	Hash  string `json:"hash"`
}

type chatHistoryIn struct {
	Repo  string `json:"repo" validate:"required,max=64"`
	Limit int    `json:"limit" validate:"min=0,max=500,default=100"`
}

type chatHistoryOut struct {
	Messages []chat.Message `json:"messages"`
	Count    int            `json:"count"`
}

type chatPostIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Text string `json:"text" validate:"required,max=4096"`
}

type chatPostOut struct {
	OK bool `json:"ok"`
}

type wikiHistoryOut struct {
	Title string          `json:"title"`
	Edits []index.WikiRev `json:"edits"`
	Count int             `json:"count"`
}

type fileGetIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Hash string `json:"hash" validate:"required,min=4,max=64"`
	Path string `json:"path" validate:"required,max=512"`
}

type fileGetOut struct {
	Hash    string `json:"hash"`
	Path    string `json:"path"`
	Size    int    `json:"size"`
	Binary  bool   `json:"binary"`
	Content string `json:"content"`
	HTML    string `json:"html"` // highlighted, sanitizer-gated; empty when no lexer matched
}

type attachmentGetIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Hash is the attachment's CONTENT artifact, not the A-card
	// artifact — the A card names what to serve.
	Hash string `json:"hash" validate:"required,min=4,max=64"`
}

type attachmentGetOut struct {
	Name string `json:"name"`
	Size int    `json:"size"`
	// Content is base64: an attachment is arbitrary bytes and JSON has
	// no binary type. The HTML route decodes and streams it as a
	// download; the API answer stays a normal JSON document.
	Content string `json:"content"`
}

// CIRuns is the forge-side record of launched pipelines. quarry does
// not model runs — dagnats owns them — this is only the mapping from a
// run back to the check-in it was for.
type CIRuns interface {
	CIRuns(repo string, limit int) ([]CIRun, error)
	CIRunsFor(repo, checkin string) ([]CIRun, error)
}

// CIRun is one launched pipeline, as the display needs it.
type CIRun struct {
	RunID    string `json:"runId"`
	Checkin  string `json:"checkin"`
	Workflow string `json:"workflow"`
	Pipeline string `json:"pipeline"`
	Status   string `json:"status"`
	Started  string `json:"started"`
}

type ciListIn struct {
	Repo  string `json:"repo" validate:"required,max=64"`
	Limit int    `json:"limit" validate:"min=0,max=200,default=50"`
}

type ciListOut struct {
	Runs  []CIRun `json:"runs"`
	Count int     `json:"count"`
}

type fileHistoryIn struct {
	Repo    string `json:"repo" validate:"required,max=64"`
	Version string `json:"version" validate:"max=100"`
	Path    string `json:"path" validate:"required,max=1024"`
	Limit   int    `json:"limit" validate:"min=0,max=200,default=50"`
}

type fileHistoryOut struct {
	Path    string              `json:"path"`
	Version string              `json:"version"`
	Changes []zitcli.FileChange `json:"changes"`
	Count   int                 `json:"count"`
}

type archiveIn struct {
	Repo    string `json:"repo" validate:"required,max=64"`
	Version string `json:"version" validate:"max=100"`
	// Format is "tar" (gzip tar, the default) or "zip". zit builds both
	// with identical selection and the same reproducibility guarantee,
	// so which container a reader gets is purely their preference.
	Format string `json:"format" validate:"oneof=tar|zip"`
}

type archiveOut struct {
	Name string `json:"name"`
	Size int    `json:"size"`
	// Version is the check-in this archive was cut from, always a full
	// hash even when the request named a branch. It is what makes the
	// bytes addressable: the same value always names the same archive,
	// which is what the caching downstream of this relies on.
	Version string `json:"version"`
	// Content is base64: an archive is bytes and JSON has no binary
	// type. The HTML route decodes and offers it as a download.
	Content string `json:"content"`
}

type branchListIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

type branchListOut struct {
	Branches []index.Branch `json:"branches"`
	Count    int            `json:"count"`
}

type treeIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Version is any name zit resolves — a branch, a tag, "trunk", or a
	// hash. Empty means the default branch's tip.
	Version string `json:"version" validate:"max=100"`
	// Path is the directory being listed; empty is the root.
	Path string `json:"path" validate:"max=1024"`
}

// treeEntry is one row of a directory listing. Dir entries are synthesised
// from the flat path set zit returns — a store has no directory objects,
// so a "directory" is a shared prefix and nothing more.
type treeEntry struct {
	Name  string `json:"name"`
	Path  string `json:"path"`
	Dir   bool   `json:"dir"`
	Blob  string `json:"blob,omitempty"`
	Files int    `json:"files,omitempty"` // for a directory: what it contains, recursively
	Size  int64  `json:"size,omitempty"`
	// Exec and Link come from the F card's permission (zit#96). A link's
	// blob content is its TARGET, so a viewer must not render it as file
	// content.
	Exec   bool `json:"exec,omitempty"`
	Link   bool `json:"link,omitempty"`
	Binary bool `json:"binary,omitempty"`
}

type treeOut struct {
	Version  string      `json:"version"`
	Path     string      `json:"path"`
	Entries  []treeEntry `json:"entries"`
	Readme   string      `json:"readme,omitempty"`   // rendered, when this directory has one
	ReadmeIn string      `json:"readmeIn,omitempty"` // which file it came from
}

type searchIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Q    string `json:"q" validate:"required,max=200"`
	// Kind restricts the search to one class of document. Empty
	// searches everything, which is the default and what a reader
	// arriving from a link expects.
	Kind  string `json:"kind" validate:"oneof=checkin|wiki|ticket|forum|technote"`
	Limit int    `json:"limit" validate:"min=0,max=200,default=50"`
}

type searchOut struct {
	Results []index.SearchResult `json:"results"`
	Count   int                  `json:"count"`
}

type searchAllIn struct {
	Q string `json:"q" validate:"required,max=200"`
	// Kind restricts to one class of document, as on the per-repo op.
	Kind string `json:"kind" validate:"oneof=checkin|wiki|ticket|forum|technote"`
	// Repo restricts to one repository — the "repo:" filter, expressed
	// as a parameter rather than as syntax inside the query so it
	// cannot collide with a term a reader meant literally.
	Repo  string `json:"repo" validate:"max=64"`
	Limit int    `json:"limit" validate:"min=0,max=200,default=50"`
}

// searchAllHit is one result with the repository it came from. The
// repository is part of the result rather than a grouping key because a
// reader scanning fleet-wide results reads them as one ranked list.
type searchAllHit struct {
	Repo string `json:"repo"`
	index.SearchResult
}

type searchAllOut struct {
	Results []searchAllHit `json:"results"`
	Count   int            `json:"count"`
	// Searched is how many repositories actually answered, which is not
	// the fleet size: private repositories the caller may not read are
	// never consulted, and a repository whose index errors is skipped.
	Searched int `json:"searched"`
	// ByRepo is the true match count per repository, so a reader can see
	// that a repository matched even when none of its hits reached the
	// first page — and by how much, which is what tells them whether it
	// is worth narrowing to.
	ByRepo []repoCount `json:"by_repo"`
}

type repoCount struct {
	Repo  string `json:"repo"`
	Count int    `json:"count"`
}

type peopleListIn struct {
	// Repo narrows to one repository; empty is the whole fleet.
	Repo string `json:"repo" validate:"max=64"`
	Q    string `json:"q" validate:"max=100"`
	// Offset pages the listing. It does NOT reduce the work: the merge
	// across repositories happens first and is the cost. Paging bounds
	// what is rendered, not what is computed — the fleet rollup that
	// fixes the second is ticketed separately.
	Offset int `json:"offset" validate:"min=0,max=1000000"`
	Limit  int `json:"limit" validate:"min=0,max=500,default=100"`
}

// person is one contributor's activity, summed across the repositories
// the caller may read. Repos names those repositories, because "who is
// this" on a forge is largely "where do they work".
type person struct {
	User   string   `json:"user"`
	Events int      `json:"events"`
	First  string   `json:"first"`
	Last   string   `json:"last"`
	Repos  []string `json:"repos"`
}

type peopleListOut struct {
	People []person `json:"people"`
	Count  int      `json:"count"`
	Total  int      `json:"total"`
}

type tagListIn struct {
	Repo  string `json:"repo" validate:"required,max=64"`
	Limit int    `json:"limit" validate:"min=0,max=500,default=100"`
}

type tagListOut struct {
	Tags  []index.Tag `json:"tags"`
	Count int         `json:"count"`
}

type diffGetIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Hash string `json:"hash" validate:"required,min=4,max=64"`
}

type diffGetOut struct {
	From  string             `json:"from"`
	To    string             `json:"to"`
	Files []zitcli.DiffEntry `json:"files"`
	// Structured is the renderable model: files, hunks, numbered lines,
	// positional syntax tokens, intra-line marks. Unified remains — it
	// is what a script wants — but stops being the only shape
	// (Principle 5: the page and the API share one capability).
	Structured []diff.FileDiff `json:"structured,omitempty"`
	Unified    string          `json:"unified"`
	Note       string          `json:"note"` // set when the diff could not be fully computed
}

type checkinGetIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Hash string `json:"hash" validate:"required,min=4,max=64"`
}

type checkinGetOut struct {
	Hash string `json:"hash"`
	// CI is the recorded verdict, read from the store's tag artifacts
	// rather than the engine — so it answers even with dagnats down,
	// and it travels with a clone.
	CI      string           `json:"ci,omitempty"`
	Branch  string           `json:"branch"`
	Date    string           `json:"date"`
	User    string           `json:"user"`
	Comment string           `json:"comment"`
	Parents []string         `json:"parents"`
	Files   []zitcli.LsEntry `json:"files"`
}

type ticketListIn struct {
	Repo   string `json:"repo" validate:"required,max=64"`
	Limit  int    `json:"limit" validate:"min=0,max=200,default=50"`
	Before string `json:"before" validate:"max=96"`
	// Closed opts INTO finished tickets. The default is the open ones:
	// a tracker list is a working surface, and across this corpus half
	// of every tracker is closed.
	Closed bool `json:"closed"`
}

// ticketSummary is the list row: id plus the two fields every tracker
// listing needs, pulled out of the folded state.
type ticketSummary struct {
	ID         string `json:"id"`
	Title      string `json:"title"`
	Status     string `json:"status"`
	Updated    string `json:"updated"`
	Created    string `json:"created,omitempty"`
	Type       string `json:"type,omitempty"`
	Severity   string `json:"severity,omitempty"`
	Priority   string `json:"priority,omitempty"`
	Subsystem  string `json:"subsystem,omitempty"`
	Resolution string `json:"resolution,omitempty"`
}

type ticketListOut struct {
	Tickets []ticketSummary `json:"tickets"`
	Count   int             `json:"count"`
	// Next is the `before` cursor for the following (older-updated)
	// page; empty when this page is the last.
	Next string `json:"next"`
}

type ticketGetIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	ID   string `json:"id" validate:"required,min=4,max=40"`
}

// ticketComment is one discussion entry with its body rendered.
type ticketComment struct {
	Date     string `json:"date"`
	Login    string `json:"login"`
	Username string `json:"username"`
	HTML     string `json:"html"`
}

type ticketGetOut struct {
	Ticket index.TicketState `json:"ticket"`
	// DescriptionHTML and Comments are the rendered forms. Fossil renders
	// a ticket's body and every comment through the wiki with the
	// mimetype recorded ALONGSIDE each one, so a plaintext comment on a
	// markdown tracker still reads as it was written.
	DescriptionHTML string          `json:"descriptionHtml"`
	Comments        []ticketComment `json:"comments"`
	// Attachments are the files currently attached to this ticket.
	// Fossil records them as their own artifacts targeting the ticket
	// id, so they are not part of TicketState's folded field set.
	Attachments []index.Attachment `json:"attachments"`
}

func fieldValue(fields []index.TicketFieldSet, name string) string {
	for _, f := range fields {
		if f.Name == name {
			return f.Value
		}
	}
	return ""
}

// resolver implements markup.Resolver over one repo's index. Wiki names
// resolve to existing pages; hash prefixes resolve to uniquely matching
// check-ins or tickets. An unresolvable or ambiguous reference renders
// as plain text, never as a dead link.
type resolver struct {
	ix   *index.Index
	repo string
}

func (r resolver) ResolveHash(prefix string) string {
	if r.ix == nil {
		return ""
	}
	if e, _, ok, err := r.ix.CheckinByPrefix(prefix); err == nil && ok {
		return "/r/" + r.repo + "/checkin/" + e.Hash
	}
	if t, ok, err := r.ix.TicketByPrefix(prefix); err == nil && ok {
		return "/r/" + r.repo + "/ticket/" + t.ID
	}
	return ""
}

func (r resolver) ResolveWiki(name string) string {
	if r.ix == nil {
		return ""
	}
	if _, ok, err := r.ix.WikiPage(name); err == nil && ok {
		return "/r/" + r.repo + "/wiki/" + url.PathEscape(name)
	}
	return ""
}

// fossilRoute splits a Fossil site-relative URL into its verb and the
// rest ("/doc/trunk/README.md" -> "doc", "trunk/README.md").
var fossilRoute = regexp.MustCompile(`^/([a-z]+)(?:[/?](.*))?$`)

// ResolvePath maps Fossil's own site-relative URLs — which imported
// wiki, technote and forum content is full of — onto quarry routes.
// Fossil verbs quarry has no page for return "" so the caller drops the
// link instead of leaving a 404 behind (libfossil's wiki alone carries
// 500 /finfo, 211 /dir and 105 /timeline references).
func (r resolver) ResolvePath(p string) string {
	m := fossilRoute.FindStringSubmatch(p)
	if m == nil {
		return ""
	}
	verb, rest := m[1], m[2]
	base := "/r/" + r.repo
	switch verb {
	case "doc":
		// /doc/<version>/<path> — embedded documentation at a check-in.
		// quarry serves the same bytes from its file route, and zit
		// resolves the symbolic version.
		if ver, path, ok := strings.Cut(rest, "/"); ok && path != "" {
			return base + "/file/" + ver + "/" + path
		}
	case "info", "artifact", "whatis":
		// /info/<hash> resolves to whatever the artifact is.
		if h, _, _ := strings.Cut(rest, "?"); h != "" {
			if u := r.ResolveHash(h); u != "" {
				return u
			}
		}
	case "timeline":
		return base + "/timeline"
	case "wiki":
		// /wiki?name=Page
		if v, err := url.ParseQuery(rest); err == nil {
			if n := v.Get("name"); n != "" {
				if u := r.ResolveWiki(n); u != "" {
					return u
				}
			}
		}
	case "forum", "forumpost", "forumthread":
		return base + "/forum"
	case "technote", "event":
		return base + "/technote"
	}
	// finfo, dir, uv, leaves, brlist, … — real Fossil pages quarry does
	// not have yet. Dropping the link beats sending a reader to a 404.
	return ""
}

// RegisterAll wires every op.
func RegisterAll(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[versionIn, versionOut]{
		Name:  "meta.version",
		Doc:   "Report the quarry build version.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/version",
		Run: func(ctx context.Context, _ versionIn) (versionOut, error) {
			return versionOut{Version: d.Version}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[wikiSaveIn, wikiSaveOut]{
		Name:  "wiki.save",
		Doc:   "Record a wiki page edit (creates the page on first save).",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/wiki/{name}",
		Run: func(ctx context.Context, in wikiSaveIn) (wikiSaveOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return wikiSaveOut{}, err
			}
			if hasControl(in.Name) {
				return wikiSaveOut{}, fmt.Errorf("wiki name has control characters: %w", ops.ErrInvalid)
			}
			mt := in.Mimetype
			if mt == "" {
				mt = authoredMimetype
			}
			var hash string
			if err := repo.write(func() (err error) {
				hash, err = repo.Zit.WikiCommit(in.Name, author(ctx), mt, []byte(in.Body))
				return err
			}); err != nil {
				return wikiSaveOut{}, err
			}
			if err := repo.synced(); err != nil {
				return wikiSaveOut{}, err
			}
			d.fireNotify(NotifyEvent{
				Workflow: "quarry-activity", To: "repo:" + in.Repo,
				Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
					"summary": "edited wiki page " + in.Name,
					"link":    "/r/" + in.Repo + "/wiki/" + in.Name},
				TxnID: "activity:wiki:" + hash,
			})
			return wikiSaveOut{Hash: hash}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ticketNewIn, ticketNewOut]{
		Name:  "ticket.new",
		Doc:   "Open a new ticket.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/ticket",
		Run: func(ctx context.Context, in ticketNewIn) (ticketNewOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return ticketNewOut{}, err
			}
			status := in.Status
			if status == "" {
				status = "Open"
			}
			fields := map[string]string{"title": in.Title, "status": status}
			for k, v := range map[string]string{
				"type": in.Type, "severity": in.Severity,
				"priority": in.Priority, "subsystem": in.Subsystem,
			} {
				if v != "" {
					fields[k] = v
				}
			}
			if in.Comment != "" {
				fields["comment"] = in.Comment
				// Say what the text IS. An absent mimetype means fossil
				// wiki (see renderBody), so a description written in the
				// Markdown box and stored without this renders with its
				// headings, fences and emphasis as literal characters —
				// correct dispatch on a card that lied by omission.
				fields["cmimetype"] = authoredMimetype
			}
			var id, hash string
			if err := repo.write(func() (err error) {
				id, hash, err = repo.Zit.TicketNew(author(ctx), fields)
				return err
			}); err != nil {
				return ticketNewOut{}, err
			}
			if err := repo.synced(); err != nil {
				return ticketNewOut{}, err
			}
			d.fireNotify(NotifyEvent{
				Workflow: "quarry-activity", To: "repo:" + in.Repo,
				Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
					"summary": "opened ticket " + in.Title,
					"link":    "/r/" + in.Repo + "/ticket/" + id},
				TxnID: "activity:ticket-new:" + hash,
			})
			return ticketNewOut{ID: id, Hash: hash}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ticketSetIn, ticketSetOut]{
		Name:  "ticket.set",
		Doc:   "Record a ticket change; empty fields are left untouched.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/ticket/{id}",
		Run: func(ctx context.Context, in ticketSetIn) (ticketSetOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return ticketSetOut{}, err
			}
			// The route accepts the id prefixes pages link with; the zit
			// verb wants the full stored id. The ALL resolver on purpose:
			// this op is token-gated, and it is the only way a private
			// ticket can ever be edited — including un-privating it.
			t, ok, err := repo.Index.TicketByPrefixAll(in.ID)
			if err != nil {
				return ticketSetOut{}, err
			}
			if !ok {
				return ticketSetOut{}, fmt.Errorf("no unique ticket for %q: %w", in.ID, ops.ErrNotFound)
			}
			fields := map[string]string{}
			for k, v := range map[string]string{
				"title": in.Title, "status": in.Status,
				"resolution": in.Resolution, "type": in.Type,
				"severity": in.Severity, "priority": in.Priority,
				"subsystem": in.Subsystem,
				// A ticket CHANGE carries its own text under icomment
				// with a plain "mimetype"; the description's is
				// "comment" with "cmimetype". Two field names for the
				// same question, which is fossil's schema and not ours
				// to rename — and writing a reply into "comment" does
				// not append it, it REPLACES the ticket's description.
				"icomment": in.Comment,
			} {
				if v != "" {
					fields[k] = v
				}
			}
			if in.Comment != "" {
				fields["mimetype"] = authoredMimetype
			}
			if len(fields) == 0 {
				return ticketSetOut{}, fmt.Errorf("no fields to set: %w", ops.ErrInvalid)
			}
			var hash string
			if err := repo.write(func() (err error) {
				hash, err = repo.Zit.TicketSet(t.ID, author(ctx), fields)
				return err
			}); err != nil {
				return ticketSetOut{}, err
			}
			if err := repo.synced(); err != nil {
				return ticketSetOut{}, err
			}
			d.fireNotify(NotifyEvent{
				Workflow: "quarry-activity", To: "repo:" + in.Repo,
				Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
					"summary": "updated ticket " + t.ID[:min(10, len(t.ID))],
					"link":    "/r/" + in.Repo + "/ticket/" + t.ID[:min(10, len(t.ID))]},
				TxnID: "activity:ticket-set:" + hash,
			})
			return ticketSetOut{Hash: hash}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[docsIn, docsOut]{
		Name:  "meta.docs",
		Doc:   "The API reference, generated from this registry and rendered.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/docs",
		Run: func(ctx context.Context, _ docsIn) (docsOut, error) {
			// r is complete by the time any op runs; the reference
			// documents the same registry that serves it.
			md := ops.Docs(r, "quarry")
			rendered, err := markup.Render(md, resolver{})
			if err != nil {
				return docsOut{}, err
			}
			return docsOut{Markdown: string(md), HTML: string(rendered)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[repoListIn, repoListOut]{
		Name:  "repo.list",
		Doc:   "List the served repositories with their tip check-in.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/repos",
		Run: func(ctx context.Context, in repoListIn) (repoListOut, error) {
			// Filtered by visibility: a listing that names a private
			// repository has leaked it, whatever the page then refuses
			// to show. Name leakage is the usual way this is got wrong.
			needle := strings.ToLower(in.Q)
			var names []string
			served := 0
			for _, name := range d.Repos.Names() {
				if d.CanRead != nil && !d.CanRead(ctx, name) {
					continue
				}
				// Counted before the filter but AFTER the visibility
				// check: the denominator is what this caller may see,
				// never the whole fleet, or it leaks how many private
				// repositories exist.
				served++
				if needle != "" && !strings.Contains(strings.ToLower(name), needle) {
					continue
				}
				names = append(names, name)
			}
			var desc map[string]string
			if d.Descriptions != nil {
				desc = d.Descriptions()
			}
			var priv map[string]bool
			if d.Private != nil {
				priv = d.Private()
			}
			var fresh map[string]string
			if d.MirrorFreshness != nil {
				fresh = d.MirrorFreshness()
			}
			rows := make([]repoInfo, 0, len(names))
			for _, name := range names {
				r, ok := d.Repos.Get(name)
				if !ok {
					continue
				}
				info := repoInfo{Name: name, Description: desc[name], Private: priv[name]}
				if d.Deletable != nil {
					info.Deletable = d.Deletable(name)
				}
				if r.Indexing != nil {
					info.Indexing = r.Indexing()
				}
				info.MirroredAt = fresh[name]
				if ix := r.Index; ix != nil {
					if c, err := ix.Counts(); err == nil {
						info.Checkins, info.Contributors = c.Checkins, c.Contributors
					}
					if last, ok, err := ix.LastEvent(); err == nil && ok {
						info.LastActive = last
					}
				}
				rows = append(rows, info)
			}
			// Sorted BEFORE the limit, or a page is an arbitrary subset
			// in the right order — which reads as correct and is not.
			switch in.Sort {
			case "size":
				sort.SliceStable(rows, func(i, j int) bool { return rows[i].Checkins > rows[j].Checkins })
			case "active":
				sort.SliceStable(rows, func(i, j int) bool { return rows[i].LastActive > rows[j].LastActive })
			}
			out := repoListOut{Total: len(rows), Served: served}
			if in.Limit > 0 && len(rows) > in.Limit {
				rows = rows[:in.Limit]
			}
			out.Repos, out.Count = rows, len(rows)
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[fleetStatusIn, fleetStatusOut]{
		Name:  "fleet.status",
		Doc:   "Report every served store's ingest health.",
		Kind:  ops.Query,
		Auth:  ops.Admin,
		Route: "GET /api/v1/fleet",
		Run: func(ctx context.Context, _ fleetStatusIn) (fleetStatusOut, error) {
			names := d.Repos.Names()
			out := fleetStatusOut{Count: len(names)}
			if d.Errors != nil {
				out.Errors = d.Errors.Recent(25)
				out.ErrorTotal, _, _ = d.Errors.Stats()
			}
			for _, name := range names {
				repo, ok := d.Repos.Get(name)
				if !ok || repo == nil {
					continue
				}
				row := fleetRepo{Name: name, Code: repo.Code}
				if repo.Indexing != nil {
					row.Indexing = repo.Indexing()
				}
				if repo.Health != nil {
					row.Health = repo.Health()
				}
				if repo.Index != nil {
					row.Checkins, _ = repo.Index.CheckinCount()
				}
				if row.Health.Failures > 0 {
					out.Unhealthy++
				}
				out.Repos = append(out.Repos, row)
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[fleetSyncIn, fleetSyncOut]{
		Name:  "fleet.sync",
		Doc:   "Run an ingest round now — one store, or every store when `repo` is empty.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/fleet/sync",
		Run: func(ctx context.Context, in fleetSyncIn) (fleetSyncOut, error) {
			var names []string
			if in.Repo != "" {
				if _, err := d.repoFor(ctx, in.Repo); err != nil {
					return fleetSyncOut{}, err
				}
				names = append(names, in.Repo)
			} else {
				names = d.Repos.Names()
			}
			var out fleetSyncOut
			for _, name := range names {
				repo, ok := d.Repos.Get(name)
				if !ok || repo == nil || repo.Sync == nil {
					continue
				}
				res := fleetSyncResult{Repo: name}
				n, err := repo.Sync()
				res.Artifacts = n
				if err != nil {
					res.Err = err.Error()
					out.Failed++
				}
				out.Results = append(out.Results, res)
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[mirrorListIn, mirrorListOut]{
		Name:  "mirror.list",
		Doc:   "List configured upstream mirrors and their recent history.",
		Kind:  ops.Query,
		Auth:  ops.Admin,
		Route: "GET /api/v1/mirrors",
		Run: func(ctx context.Context, _ mirrorListIn) (mirrorListOut, error) {
			if d.Mirrors == nil {
				return mirrorListOut{}, nil // mirroring unconfigured: an empty list, not an error
			}
			ms, err := d.Mirrors.Mirrors()
			if err != nil {
				return mirrorListOut{}, err
			}
			return mirrorListOut{Mirrors: ms, Count: len(ms)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[mirrorSetIn, mirrorSetOut]{
		Name:  "mirror.set",
		Doc:   "Configure a store's upstream and pull interval.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/mirrors",
		Run: func(ctx context.Context, in mirrorSetIn) (mirrorSetOut, error) {
			if d.Mirrors == nil {
				return mirrorSetOut{}, fmt.Errorf("mirroring is not configured: %w", ops.ErrUnavailable)
			}
			// The store must be one quarry serves. Configuring a
			// mirror for a name that resolves to nothing produces a
			// row the scheduler will skip forever, which reads as
			// "mirroring is broken" rather than "that repo is a typo".
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return mirrorSetOut{}, err
			}
			if err := d.Mirrors.SetMirror(in.Repo, in.URL, in.Every); err != nil {
				return mirrorSetOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
			}
			m, _, err := d.Mirrors.MirrorFor(in.Repo)
			if err != nil {
				return mirrorSetOut{}, err
			}
			return mirrorSetOut{Mirror: m}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[mirrorDeleteIn, mirrorDeleteOut]{
		Name:  "mirror.delete",
		Doc:   "Stop mirroring a store and forget its upstream.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/mirrors/delete",
		Run: func(ctx context.Context, in mirrorDeleteIn) (mirrorDeleteOut, error) {
			if d.Mirrors == nil {
				return mirrorDeleteOut{}, fmt.Errorf("mirroring is not configured: %w", ops.ErrUnavailable)
			}
			if err := d.Mirrors.DeleteMirror(in.Repo); err != nil {
				return mirrorDeleteOut{}, err
			}
			return mirrorDeleteOut{Repo: in.Repo}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[mirrorRunIn, mirrorRunOut]{
		Name:  "mirror.run",
		Doc:   "Pull from upstream now — one store, or every configured mirror when `repo` is empty.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/mirrors/run",
		Run: func(ctx context.Context, in mirrorRunIn) (mirrorRunOut, error) {
			if d.Mirrors == nil {
				return mirrorRunOut{}, fmt.Errorf("mirroring is not configured: %w", ops.ErrUnavailable)
			}
			ms, err := d.Mirrors.Mirrors()
			if err != nil {
				return mirrorRunOut{}, err
			}
			var out mirrorRunOut
			for _, m := range ms {
				if in.Repo != "" && m.Repo != in.Repo {
					continue
				}
				res := mirrorRunResult{Repo: m.Repo}
				changed, err := d.PullMirror(m)
				res.Changed = changed
				if err != nil {
					res.Err = err.Error()
					out.Failed++
				} else if changed {
					out.Changed++
				}
				out.Results = append(out.Results, res)
			}
			if in.Repo != "" && len(out.Results) == 0 {
				return out, fmt.Errorf("no mirror configured for %q: %w", in.Repo, ops.ErrNotFound)
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[accountListIn, accountListOut]{
		Name:  "account.list",
		Doc:   "List forge accounts and their per-repository grants.",
		Kind:  ops.Query,
		Auth:  ops.Admin,
		Route: "GET /api/v1/accounts",
		Run: func(ctx context.Context, _ accountListIn) (accountListOut, error) {
			if d.Accounts == nil {
				return accountListOut{}, errNoAccounts()
			}
			accts, err := d.Accounts.Accounts()
			if err != nil {
				return accountListOut{}, err
			}
			out := accountListOut{Count: len(accts)}
			for _, a := range accts {
				row := accountRow{Account: a}
				grants, err := d.Accounts.GrantsFor(a.Name)
				if err != nil {
					return accountListOut{}, err
				}
				repos := make([]string, 0, len(grants))
				for repo := range grants {
					repos = append(repos, repo)
				}
				sort.Strings(repos)
				for _, repo := range repos {
					row.Grants = append(row.Grants, accountGrant{Repo: repo, Role: grants[repo]})
				}
				out.Accounts = append(out.Accounts, row)
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[accountAddIn, accountAddOut]{
		Name:  "account.add",
		Doc:   "Create a forge account. The password is hashed with argon2id and never stored or returned in plaintext.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/accounts",
		Run: func(ctx context.Context, in accountAddIn) (accountAddOut, error) {
			if d.Accounts == nil {
				return accountAddOut{}, errNoAccounts()
			}
			if err := d.Accounts.AddAccount(in.Name, in.Password, in.Admin); err != nil {
				// Scrubbed deliberately: the store's error can quote what
				// it was given, and what it was given is a password.
				return accountAddOut{}, fmt.Errorf("%w: %s", ops.ErrInvalid, scrub(err, in.Password))
			}
			return accountAddOut{Name: in.Name, Admin: in.Admin}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[accountPasswdIn, accountNameOut]{
		Name:  "account.passwd",
		Doc:   "Set an account's password.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/accounts/passwd",
		Run: func(ctx context.Context, in accountPasswdIn) (accountNameOut, error) {
			if d.Accounts == nil {
				return accountNameOut{}, errNoAccounts()
			}
			if err := d.Accounts.SetPassword(in.Name, in.Password); err != nil {
				return accountNameOut{}, fmt.Errorf("%w: %s", ops.ErrInvalid, scrub(err, in.Password))
			}
			return accountNameOut{Name: in.Name}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[accountDeleteIn, accountNameOut]{
		Name:  "account.delete",
		Doc:   "Delete an account and all of its grants.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/accounts/delete",
		Run: func(ctx context.Context, in accountDeleteIn) (accountNameOut, error) {
			if d.Accounts == nil {
				return accountNameOut{}, errNoAccounts()
			}
			if err := d.Accounts.DeleteAccount(in.Name); err != nil {
				return accountNameOut{}, err
			}
			return accountNameOut{Name: in.Name}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[accountGrantIn, accountGrantOut]{
		Name:  "account.grant",
		Doc:   "Grant an account read, write or admin on one repository.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/accounts/grant",
		Run: func(ctx context.Context, in accountGrantIn) (accountGrantOut, error) {
			if d.Accounts == nil {
				return accountGrantOut{}, errNoAccounts()
			}
			// The repository must be one quarry serves. Granting against
			// a name that resolves to nothing produces a row that looks
			// like access and grants none.
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return accountGrantOut{}, err
			}
			if err := d.Accounts.Grant(in.Name, in.Repo, in.Role); err != nil {
				return accountGrantOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
			}
			return accountGrantOut{Name: in.Name, Repo: in.Repo, Role: in.Role}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[accountRevokeIn, accountGrantOut]{
		Name:  "account.revoke",
		Doc:   "Remove an account's grant on one repository.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/accounts/revoke",
		Run: func(ctx context.Context, in accountRevokeIn) (accountGrantOut, error) {
			if d.Accounts == nil {
				return accountGrantOut{}, errNoAccounts()
			}
			if err := d.Accounts.Revoke(in.Name, in.Repo); err != nil {
				return accountGrantOut{}, err
			}
			return accountGrantOut{Name: in.Name, Repo: in.Repo}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[repoGetIn, repoInfo]{
		Name:  "repo.get",
		Doc:   "Describe one repository: its settings and headline counts.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/info",
		Run: func(ctx context.Context, in repoGetIn) (repoInfo, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return repoInfo{}, err
			}
			info := repoInfo{Name: in.Repo, FossilClone: repo.FossilClone}
			if d.Descriptions != nil {
				info.Description = d.Descriptions()[in.Repo]
			}
			if d.Private != nil {
				info.Private = d.Private()[in.Repo]
			}
			if d.Deletable != nil {
				info.Deletable = d.Deletable(in.Repo)
			}
			if repo.Indexing != nil {
				info.Indexing = repo.Indexing()
			}
			if ix := repo.Index; ix != nil {
				info.Checkins, _ = ix.CheckinCount()
				if tip, _, err := ix.Timeline(1, "", "", "ci"); err == nil && len(tip) == 1 {
					info.TipDate, info.TipComment = tip[0].Date, tip[0].Comment
				}
				if last, ok, err := ix.LastEvent(); err == nil && ok {
					info.LastActive = last
				}
			}
			return info, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[repoAddIn, repoAddOut]{
		Name:  "repo.add",
		Doc:   "Serve a repository: an existing store at `path`, a new empty one (`create`), or a clone of `clone`. Cloning also records a mirror unless `noMirror`.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/repos",
		Run: func(ctx context.Context, in repoAddIn) (repoAddOut, error) {
			if d.Registry == nil {
				return repoAddOut{}, fmt.Errorf("this node cannot register repositories: %w", ops.ErrUnavailable)
			}
			if in.Create && in.Clone != "" {
				return repoAddOut{}, fmt.Errorf(
					"create and clone are alternatives, not both: %w", ops.ErrInvalid)
			}
			if in.Clone != "" && (in.Readme || in.License != "" || in.Ignore) {
				return repoAddOut{}, fmt.Errorf(
					"a cloned repository already has a history to seed: %w", ops.ErrInvalid)
			}
			spec := RepoSpec{
				Name: in.Name, Path: in.Path, Description: in.Description,
				Visibility: visibilityOf(in.Private),
				Create:     in.Create, Clone: in.Clone,
				Readme: in.Readme, License: in.License, Ignore: in.Ignore,
				Author: ops.User(ctx),
				Mirror: in.Clone != "" && !in.NoMirror, MirrorEvery: in.MirrorEvery,
			}
			path, err := d.Registry.Add(spec)
			if err != nil {
				return repoAddOut{}, err
			}
			return repoAddOut{Name: in.Name, Path: path, Mirrored: spec.Mirror}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[repoUpdateIn, repoUpdateOut]{
		Name:  "repo.update",
		Doc:   "Set a repository's settings: description and visibility. Fields are applied whole.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/repos/update",
		Run: func(ctx context.Context, in repoUpdateIn) (repoUpdateOut, error) {
			if d.Registry == nil {
				return repoUpdateOut{}, fmt.Errorf("this node cannot register repositories: %w", ops.ErrUnavailable)
			}
			if _, err := d.repoFor(ctx, in.Name); err != nil {
				return repoUpdateOut{}, err
			}
			if err := d.Registry.Update(in.Name, in.Description, visibilityOf(in.Private)); err != nil {
				return repoUpdateOut{}, err
			}
			return repoUpdateOut{Name: in.Name, Description: in.Description, Private: in.Private}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[repoDeleteIn, repoRemoveOut]{
		Name:  "repo.delete",
		Doc:   "Destroy a repository: its store, its index and everything keyed to it. `confirm` must equal `name`. Only repositories quarry created can be deleted.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/repos/delete",
		Run: func(ctx context.Context, in repoDeleteIn) (repoRemoveOut, error) {
			if d.Registry == nil {
				return repoRemoveOut{}, fmt.Errorf("this node cannot register repositories: %w", ops.ErrUnavailable)
			}
			if _, err := d.repoFor(ctx, in.Name); err != nil {
				return repoRemoveOut{}, err
			}
			if err := d.Registry.Delete(in.Name, in.Confirm); err != nil {
				return repoRemoveOut{}, err
			}
			return repoRemoveOut{Name: in.Name}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[repoRemoveIn, repoRemoveOut]{
		Name:  "repo.remove",
		Doc:   "Stop serving a repository. The store on disk is left untouched.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/repos/remove",
		Run: func(ctx context.Context, in repoRemoveIn) (repoRemoveOut, error) {
			if d.Registry == nil {
				return repoRemoveOut{}, fmt.Errorf("this node cannot register repositories: %w", ops.ErrUnavailable)
			}
			if err := d.Registry.Remove(in.Name); err != nil {
				return repoRemoveOut{}, err
			}
			return repoRemoveOut{Name: in.Name}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[repoCountsIn, repoCountsOut]{
		Name:  "repo.counts",
		Doc:   "Count a repository's content by section — what the navigation shows.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/counts",
		Run: func(ctx context.Context, in repoCountsIn) (repoCountsOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return repoCountsOut{}, err
			}
			c, err := repo.Index.Counts()
			if err != nil {
				return repoCountsOut{}, err
			}
			return repoCountsOut{
				Checkins: c.Checkins, WikiPages: c.WikiPages, Tickets: c.Tickets,
				Contributors: c.Contributors, Tags: c.Tags,
				ForumThreads: c.ForumThreads, Technotes: c.Technotes,
			}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[timelineIn, timelineOut]{
		Name:  "timeline.list",
		Doc:   "List a repository's events — check-ins, wiki edits, ticket changes — newest first, paged by `before`.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/timeline",
		Run: func(ctx context.Context, in timelineIn) (timelineOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return timelineOut{}, err
			}
			entries, more, err := repo.Index.Timeline(in.Limit, in.Before, in.Branch, in.Y)
			if err != nil {
				return timelineOut{}, err
			}
			// Leaf status comes from zit, not from a query over the
			// folded rows. quarry's own predicate answered "no child of
			// any kind", which the repo-state RFC §7.2 names explicitly
			// as NOT the canonical test — the real one is "no child on
			// the same branch", and open leaves additionally exclude an
			// active closed tag. On fossil the difference is 490 marked
			// against a true 1432, and a different set. Asking is ~10ms
			// against zit's own derivation cache; reproducing it is
			// several RFC sections and a standing risk of drifting into
			// showing false data about a store that is entirely correct.
			if repo.Leaves == nil {
				// No store handle (tests, and any caller that wires an
				// index without one): leave every entry unmarked rather
				// than marked by a rule zit does not use.
				for i := range entries {
					entries[i].IsLeaf = false
				}
			} else if leaves, lerr := repo.Leaves(); lerr == nil {
				for i := range entries {
					entries[i].IsLeaf = entries[i].Kind == "ci" && leaves[entries[i].Hash]
				}
			} else {
				log.Printf("timeline %s: leaves: %v", in.Repo, lerr)
			}
			out := timelineOut{Entries: entries, Count: len(entries)}
			if more && len(entries) > 0 {
				last := entries[len(entries)-1]
				out.Next = last.Date + "~" + last.Hash
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[wikiListIn, wikiListOut]{
		Name:  "wiki.list",
		Doc:   "List a repository's wiki pages with their latest edit.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/wiki",
		Run: func(ctx context.Context, in wikiListIn) (wikiListOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return wikiListOut{}, err
			}
			pages, err := repo.Index.WikiTitles()
			if err != nil {
				return wikiListOut{}, err
			}
			return wikiListOut{Pages: pages, Count: len(pages)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[reindexIn, reindexOut]{
		Name:  "repo.reindex",
		Doc:   "Wipe this repository's derived index and replay it from the store, in process, while the forge serves. The sanctioned repair for any index that disagrees with its store.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/r/{repo}/reindex",
		Run: func(ctx context.Context, in reindexIn) (reindexOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return reindexOut{}, err
			}
			if repo.Reindex == nil {
				return reindexOut{}, fmt.Errorf("this deployment cannot reindex in process: %w", ops.ErrInvalid)
			}
			n, err := repo.Reindex()
			if err != nil {
				return reindexOut{}, err
			}
			return reindexOut{Artifacts: n}, nil
		},
	}); err != nil {
		return err
	}

	if err := ops.Register(r, ops.Op[meFeedIn, meFeedOut]{
		Name:  "me.feed",
		Doc:   "The signed-in dashboard: the caller's granted repositories ordered by activity, the merged activity feed across them, and the caller's own recorded work.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/me/feed",
		Run: func(ctx context.Context, _ meFeedIn) (meFeedOut, error) {
			user := ops.User(ctx)
			if user == "" || d.Accounts == nil {
				return meFeedOut{}, fmt.Errorf("no signed-in account: %w", ops.ErrNotFound)
			}
			grants, err := d.Accounts.GrantsFor(user)
			if err != nil {
				return meFeedOut{}, err
			}
			var names []string
			if _, site := grants[""]; site {
				// A site admin's forge IS the fleet.
				names = d.Repos.Names()
			} else {
				for repo := range grants {
					if repo != "" {
						names = append(names, repo)
					}
				}
				sort.Strings(names)
			}
			// A dashboard is a glance, not an audit: bounded per-repo
			// reads (K grants × one indexed page each), merged in memory.
			const perRepo, feedCap, mineCap, repoCap = 50, 50, 20, 25
			if len(names) > repoCap {
				names = names[:repoCap]
			}
			var desc map[string]string
			if d.Descriptions != nil {
				desc = d.Descriptions()
			}
			out := meFeedOut{User: user}
			var all []feedEvent
			for _, name := range names {
				repo, ok := d.Repos.Get(name)
				if !ok || repo.Index == nil {
					continue
				}
				entries, _, terr := repo.Index.Timeline(perRepo, "", "", "")
				if terr != nil {
					continue // one broken index must not empty the page
				}
				dr := dashRepo{Name: name, Description: desc[name]}
				if len(entries) > 0 {
					dr.LastActivity = entries[0].Date
				}
				out.Repos = append(out.Repos, dr)
				for _, e := range entries {
					all = append(all, feedEvent{Repo: name, Kind: e.Kind,
						Ref: e.Ref, Hash: e.Hash, Date: e.Date,
						User: e.User, Comment: e.Comment})
				}
			}
			sort.SliceStable(out.Repos, func(i, j int) bool {
				return out.Repos[i].LastActivity > out.Repos[j].LastActivity
			})
			sort.SliceStable(all, func(i, j int) bool { return all[i].Date > all[j].Date })
			for _, e := range all {
				if len(out.Feed) < feedCap {
					out.Feed = append(out.Feed, e)
				}
				if e.User == user && len(out.Mine) < mineCap {
					out.Mine = append(out.Mine, e)
				}
			}
			return out, nil
		},
	}); err != nil {
		return err
	}

	if err := ops.Register(r, ops.Op[aboutIn, aboutOut]{
		Name:  "repo.about",
		Doc:   "The repository's About aggregate: description, homepage and topics (.quarry/repo.json), the landing page's document tabs, the latest release tag, and the contributor count. One op so the panel costs one invoke and the JSON API sees the same panel.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/about",
		Run: func(ctx context.Context, in aboutIn) (aboutOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return aboutOut{}, err
			}
			out := aboutOut{}
			if d.Descriptions != nil {
				out.Description = d.Descriptions()[in.Repo]
			}
			version := "trunk"
			if repo.Index != nil {
				if tip, ok, terr := repo.Index.TipOf("trunk"); terr == nil && ok {
					version = tip.Tip
				}
				if tags, terr := repo.Index.Tags(1); terr == nil && len(tags) > 0 {
					out.LatestTag, out.LatestTagDate = tags[0].Name, tags[0].Date
				}
				if c, cerr := repo.Index.Counts(); cerr == nil {
					out.Contributors = c.Contributors
				}
			}
			var files []zitcli.LsEntry
			if repo.Zit != nil {
				// The memoized tip listing answers both the config read
				// and the marker scan.
				if ls, lerr := repo.Zit.Ls(ctx, version); lerr == nil {
					files = ls
				}
				raw, cerr := repo.Zit.Cat(ctx, repoConfigPath, version)
				switch {
				case errors.Is(cerr, zitcli.ErrNoEntry):
					// no file: convention alone
				case cerr != nil:
					// a read failure is not a config, and not fatal
				default:
					home, topics, docs, perr := parseRepoConfig(raw)
					if perr != nil {
						out.Problem = perr.Error()
					} else {
						out.Homepage, out.Topics = home, topics
						out.Docs = docs
					}
				}
			}
			if len(out.Docs) == 0 && out.Problem == "" {
				out.Docs = markerDocs(files)
			}
			if len(out.Docs) > 0 && repo.Zit != nil {
				sel := out.Docs[0]
				if in.Doc != "" {
					found := false
					for _, dcc := range out.Docs {
						if dcc.Path == in.Doc {
							sel, found = dcc, true
							break
						}
					}
					if !found {
						return aboutOut{}, fmt.Errorf("no document tab %q: %w", in.Doc, ops.ErrNotFound)
					}
				}
				if body, berr := repo.Zit.Cat(ctx, sel.Path, version); berr == nil {
					if html, herr := renderBody(repo.Index, in.Repo, mimetypeOf(sel.Path), body); herr == nil {
						out.DocPath, out.DocLabel, out.DocHTML = sel.Path, sel.Label, html
					}
				}
			}
			return out, nil
		},
	}); err != nil {
		return err
	}

	if err := ops.Register(r, ops.Op[ticketConfigIn, ticketConfigOut]{
		Name:  "ticket.config",
		Doc:   "The repository's ticket vocabulary: .quarry/tickets.json at the tip of trunk, merged over fossil's defaults. List order is sort order.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/ticket-config",
		Run: func(ctx context.Context, in ticketConfigIn) (ticketConfigOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return ticketConfigOut{}, err
			}
			merged := map[string][]string{}
			for k, v := range DefaultTicketVocabulary {
				merged[k] = v
			}
			out := ticketConfigOut{Source: "default"}
			// Read at trunk's TIP HASH, not at the name "trunk": cat
			// memoizes immutable versions only, and this runs on every
			// tickets render — by name it was two zit subprocesses per
			// page. The index's tip is at worst one poll behind the
			// store, which for a vocabulary is a shorter delay than a
			// reader noticing their config commit.
			version := "trunk"
			if repo.Index != nil {
				if tip, ok, terr := repo.Index.TipOf("trunk"); terr == nil && ok {
					version = tip.Tip
				}
			}
			var raw []byte
			err = zitcli.ErrNoEntry // no store client reads as "no file"
			if repo.Zit != nil {
				raw, err = repo.Zit.Cat(ctx, ticketConfigPath, version)
			}
			switch {
			case errors.Is(err, zitcli.ErrNoEntry):
				// No file: the defaults ARE the config.
			case err != nil:
				return ticketConfigOut{}, err
			default:
				if over, perr := parseTicketVocab(raw); perr != nil {
					// The page renders defaults rather than erroring;
					// this is how an author learns their file did
					// nothing.
					out.Problem = perr.Error()
				} else {
					for k, v := range over {
						merged[k] = v
					}
					out.Source = ticketConfigPath
				}
			}
			for _, f := range vocabFieldOrder {
				out.Vocab = append(out.Vocab, vocabField{Field: f, Words: merged[f]})
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ticketListIn, ticketListOut]{
		Name:  "ticket.list",
		Doc:   "List a repository's tickets with their folded current state.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/ticket",
		Run: func(ctx context.Context, in ticketListIn) (ticketListOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return ticketListOut{}, err
			}
			all, more, err := repo.Index.TicketSummaries(in.Limit, in.Before, in.Closed)
			if err != nil {
				return ticketListOut{}, err
			}
			out := ticketListOut{Count: len(all)}
			for _, t := range all {
				out.Tickets = append(out.Tickets, ticketSummary{
					ID: t.ID, Title: t.Title, Status: t.Status, Updated: t.Updated, Created: t.Created,
					Type: t.Type, Severity: t.Severity, Priority: t.Priority,
					Subsystem: t.Subsystem, Resolution: t.Resolution,
				})
			}
			if more && len(all) > 0 {
				last := all[len(all)-1]
				out.Next = last.Updated + "~" + last.ID
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ticketGetIn, ticketGetOut]{
		Name:  "ticket.get",
		Doc:   "Fetch one ticket: folded state plus change history.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/ticket/{id}",
		Run: func(ctx context.Context, in ticketGetIn) (ticketGetOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return ticketGetOut{}, err
			}
			t, ok, err := repo.Index.TicketByPrefix(in.ID)
			if err != nil {
				return ticketGetOut{}, err
			}
			if !ok {
				return ticketGetOut{}, fmt.Errorf("no unique ticket for %q: %w", in.ID, ops.ErrNotFound)
			}
			// Attachments key off the FULL ticket id, not the prefix the
			// caller passed.
			att, err := repo.Index.AttachmentsFor(t.ID)
			if err != nil {
				return ticketGetOut{}, err
			}
			out := ticketGetOut{Ticket: t, Attachments: att}
			if t.Description != "" {
				html, err := renderBody(repo.Index, in.Repo, t.DescriptionMimetype, []byte(t.Description))
				if err != nil {
					return ticketGetOut{}, err
				}
				out.DescriptionHTML = html
			}
			for _, c := range t.Comments {
				html, err := renderBody(repo.Index, in.Repo, c.Mimetype, []byte(c.Body))
				if err != nil {
					return ticketGetOut{}, err
				}
				out.Comments = append(out.Comments, ticketComment{
					Date: c.Date, Login: c.Login, Username: c.Username, HTML: html,
				})
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[attachmentGetIn, attachmentGetOut]{
		Name:  "attachment.get",
		Doc:   "Fetch one attachment's content by its content-artifact hash.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/attachment/{hash}",
		Run: func(ctx context.Context, in attachmentGetIn) (attachmentGetOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return attachmentGetOut{}, err
			}
			// The A card is the authority on what this blob is called and
			// whether it is still attached. Serving a blob the index does
			// not know as a live attachment would turn this route into a
			// general read-any-artifact endpoint.
			name, ok, err := repo.Index.AttachmentName(in.Hash)
			if err != nil {
				return attachmentGetOut{}, err
			}
			if !ok {
				return attachmentGetOut{}, fmt.Errorf("no attachment %q: %w", in.Hash, ops.ErrNotFound)
			}
			raw, err := repo.Zit.ArtifactRaw(ctx, in.Hash, maxAttachmentBytes)
			if err != nil {
				if errors.Is(err, zitcli.ErrNoEntry) {
					return attachmentGetOut{}, fmt.Errorf("attachment content unavailable: %w", ops.ErrNotFound)
				}
				return attachmentGetOut{}, err
			}
			return attachmentGetOut{Name: name, Size: len(raw),
				Content: base64.StdEncoding.EncodeToString(raw)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ciListIn, ciListOut]{
		Name:  "ci.list",
		Doc:   "List a repository's CI runs, newest first.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/ci",
		Run: func(ctx context.Context, in ciListIn) (ciListOut, error) {
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return ciListOut{}, err
			}
			if d.CI == nil {
				return ciListOut{}, nil // no engine: an empty list, not an error
			}
			runs, err := d.CI.CIRuns(in.Repo, in.Limit)
			if err != nil {
				return ciListOut{}, err
			}
			return ciListOut{Runs: runs, Count: len(runs)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[fileHistoryIn, fileHistoryOut]{
		Name:  "file.history",
		Doc:   "List the check-ins that changed one file, newest first.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/history/{version}/{path...}",
		Run: func(ctx context.Context, in fileHistoryIn) (fileHistoryOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return fileHistoryOut{}, err
			}
			version := d.resolveVersion(repo, in.Version)
			// zit answers this directly (zit#93); quarry has no index of
			// per-file history and building one would duplicate the DAG.
			changes, err := repo.Zit.FileHistory(ctx, version, in.Path, in.Limit)
			if err != nil {
				return fileHistoryOut{}, err
			}
			return fileHistoryOut{Path: in.Path, Version: version,
				Changes: changes, Count: len(changes)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[archiveIn, archiveOut]{
		Name:  "archive.get",
		Doc:   "Download a check-in's files as a reproducible archive (tar.gz or zip).",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/archive/{version}",
		Run: func(ctx context.Context, in archiveIn) (archiveOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return archiveOut{}, err
			}
			format := in.Format
			if format == "" {
				format = "tar"
			}
			version := d.resolveVersion(repo, in.Version)
			// Resolve to the check-in a name currently means, so the
			// archive's directory and filename say WHICH commit this is
			// rather than "trunk", which will mean something else
			// tomorrow. It is also the cache key: a branch name is not
			// one, because what it points at changes.
			short := version
			if b, ok, err := repo.Index.TipOf(version); err == nil && ok {
				short = b.Tip[:min(12, len(b.Tip))]
				version = b.Tip
			} else if len(short) > 12 {
				short = short[:12]
			}
			name := in.Repo + "-" + short
			suffix := ".tar.gz"
			if format == "zip" {
				suffix = ".zip"
			}

			build := func(dst string) error {
				return d.Bulk.Do(ctx, func() error {
					return repo.Zit.ArchiveTo(ctx, format, version, name, dst, maxArchiveBytes)
				})
			}
			var path string
			if d.Archives != nil {
				// An archive is a pure function of a resolved check-in
				// and a container, so the key needs nothing else and an
				// entry is never stale.
				path, err = d.Archives.Get(in.Repo+"/"+version+"."+format, build)
			} else {
				path, err = tempArchive(build)
				if path != "" {
					defer os.Remove(path)
				}
			}
			if errors.Is(err, work.ErrBusy) {
				return archiveOut{}, fmt.Errorf("archive queue is full, retry shortly: %w", ops.ErrUnavailable)
			}
			if err != nil {
				log.Printf("archive %s@%s (%s): %v", in.Repo, version, format, err)
				return archiveOut{}, fmt.Errorf("%w: %v", ops.ErrUnavailable, err)
			}
			raw, err := os.ReadFile(path)
			if err != nil {
				return archiveOut{}, fmt.Errorf("%w: %v", ops.ErrUnavailable, err)
			}
			return archiveOut{Name: name + suffix, Size: len(raw), Version: version,
				Content: base64.StdEncoding.EncodeToString(raw)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[tagListIn, tagListOut]{
		Name:  "tag.list",
		Doc:   "List release tags — symbolic names that are not branches.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/tag",
		Run: func(ctx context.Context, in tagListIn) (tagListOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return tagListOut{}, err
			}
			tags, err := repo.Index.Tags(in.Limit)
			if err != nil {
				return tagListOut{}, err
			}
			return tagListOut{Tags: tags, Count: len(tags)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[branchListIn, branchListOut]{
		Name:  "branch.list",
		Doc:   "List branches with their tip check-in, most recently active first.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/branch",
		Run: func(ctx context.Context, in branchListIn) (branchListOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return branchListOut{}, err
			}
			bs, err := repo.Index.Branches()
			if err != nil {
				return branchListOut{}, err
			}
			return branchListOut{Branches: bs, Count: len(bs)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[treeIn, treeOut]{
		Name:  "tree.list",
		Doc:   "List one directory of a check-in, with the directory's README rendered.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/tree/{version}/{path...}",
		Run: func(ctx context.Context, in treeIn) (treeOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return treeOut{}, err
			}
			version := d.resolveVersion(repo, in.Version)
			// Resolve a moving name to the check-in it currently means.
			// Two reasons: the listing is then cacheable (a hash cannot
			// move, "trunk" can), and the page's links carry the exact
			// check-in a reader was looking at rather than whatever is
			// newest by the time they click.
			if b, ok, err := repo.Index.TipOf(version); err == nil && ok {
				version = b.Tip
			}
			prefix := strings.Trim(in.Path, "/")
			// --under asks zit for this subtree only, so drawing one
			// directory no longer materialises every path in the
			// repository (zit#96).
			files, err := repo.Zit.LsUnder(ctx, version, prefix)
			if err != nil {
				return treeOut{}, fmt.Errorf("%w: %v", ops.ErrNotFound, err)
			}
			if prefix != "" {
				prefix += "/"
			}
			// A store keeps no directories, only paths. One level of the
			// tree is therefore the distinct next segment of every path
			// under this prefix — files carry their blob, directories
			// carry how much lives beneath them.
			dirs := map[string]int{}
			var entries []treeEntry
			for _, f := range files {
				if !strings.HasPrefix(f.Name, prefix) {
					continue
				}
				rest := f.Name[len(prefix):]
				if i := strings.IndexByte(rest, '/'); i >= 0 {
					dirs[rest[:i]]++
					continue
				}
				entries = append(entries, treeEntry{
					Name: rest, Path: prefix + rest, Blob: f.Blob,
					Size: f.Size, Exec: f.Executable(), Link: f.Symlink(),
					Binary: f.Binary,
				})
			}
			for name, n := range dirs {
				entries = append(entries, treeEntry{
					Name: name, Path: prefix + name, Dir: true, Files: n,
				})
			}
			// Directories first, then names — the ordering every file
			// browser uses, because a tree read alphabetically with files
			// interleaved is hard to scan.
			sort.Slice(entries, func(i, j int) bool {
				if entries[i].Dir != entries[j].Dir {
					return entries[i].Dir
				}
				return entries[i].Name < entries[j].Name
			})
			out := treeOut{Version: version, Path: strings.Trim(in.Path, "/"), Entries: entries}

			// The README of THIS directory, rendered — what a forge shows
			// under the listing.
			for _, e := range entries {
				if e.Dir || !isReadme(e.Name) {
					continue
				}
				body, err := repo.Zit.Cat(ctx, e.Path, version)
				if err != nil {
					break // unreadable README is not a failed listing
				}
				html, err := renderBody(repo.Index, in.Repo, mimetypeOf(e.Name), body)
				if err != nil {
					break
				}
				out.Readme, out.ReadmeIn = html, e.Name
				break
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[peopleListIn, peopleListOut]{
		Name:  "people.list",
		Doc:   "List contributors across every repository the caller may read.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/people",
		Run: func(ctx context.Context, in peopleListIn) (peopleListOut, error) {
			names := d.Repos.Names()
			if in.Repo != "" {
				if _, err := d.repoFor(ctx, in.Repo); err != nil {
					return peopleListOut{}, err
				}
				names = []string{in.Repo}
			}
			// These are the names on artifacts a reader can already
			// read, so this exposes nothing new — but a repository they
			// may NOT read must not contribute a name or a count, or
			// the listing leaks that it exists.
			merged := map[string]*person{}
			for _, name := range names {
				if d.CanRead != nil && !d.CanRead(ctx, name) {
					continue
				}
				repo, err := d.repo(name)
				if err != nil {
					continue
				}
				people, err := repo.Index.Contributors(0)
				if err != nil {
					log.Printf("people.list %s: %v", name, err)
					continue
				}
				for _, c := range people {
					p := merged[c.User]
					if p == nil {
						p = &person{User: c.User, First: c.First, Last: c.Last}
						merged[c.User] = p
					}
					p.Events += c.Events
					p.Repos = append(p.Repos, name)
					if c.First < p.First {
						p.First = c.First
					}
					if c.Last > p.Last {
						p.Last = c.Last
					}
				}
			}
			needle := strings.ToLower(in.Q)
			out := peopleListOut{}
			for _, p := range merged {
				if needle != "" && !strings.Contains(strings.ToLower(p.User), needle) {
					continue
				}
				sort.Strings(p.Repos)
				out.People = append(out.People, *p)
			}
			out.Total = len(out.People)
			sort.Slice(out.People, func(i, j int) bool {
				if out.People[i].Events != out.People[j].Events {
					return out.People[i].Events > out.People[j].Events
				}
				return out.People[i].User < out.People[j].User
			})
			if in.Offset > 0 {
				if in.Offset >= len(out.People) {
					out.People = nil
				} else {
					out.People = out.People[in.Offset:]
				}
			}
			if in.Limit > 0 && len(out.People) > in.Limit {
				out.People = out.People[:in.Limit]
			}
			out.Count = len(out.People)
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[searchAllIn, searchAllOut]{
		Name:  "search.all",
		Doc:   "Full-text search across every repository the caller may read.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/search",
		Run: func(ctx context.Context, in searchAllIn) (searchAllOut, error) {
			names := d.Repos.Names()
			if in.Repo != "" {
				// A named repository still goes through repoFor, so an
				// unreadable one reports NOT FOUND rather than being
				// silently dropped to an empty result.
				if _, err := d.repoFor(ctx, in.Repo); err != nil {
					return searchAllOut{}, err
				}
				names = []string{in.Repo}
			}

			// Fan out concurrently. Each index is its own SQLite file
			// and every query is a read, so there is nothing to
			// serialize; the bound is only to stop a fleet of thousands
			// opening thousands of queries at once.
			type answer struct {
				repo  string
				hits  []index.SearchResult
				total int
			}
			sem := make(chan struct{}, searchFanout)
			results := make([]answer, len(names))
			var wg sync.WaitGroup
			for i, name := range names {
				if d.CanRead != nil && !d.CanRead(ctx, name) {
					// Never consulted, never counted: a fleet-wide
					// search must not reveal that a private repository
					// exists, and a hit count that moves with it would.
					continue
				}
				repo, err := d.repo(name)
				if err != nil {
					continue
				}
				wg.Add(1)
				go func() {
					defer wg.Done()
					sem <- struct{}{}
					defer func() { <-sem }()
					// Each repo is asked for the full limit: the top
					// results overall may all come from one repository,
					// so trimming per repo would lose them.
					hits, err := repo.Index.Search(in.Q, in.Kind, in.Limit)
					if err != nil {
						log.Printf("search.all %s: %v", name, err)
						return
					}
					// The true count, not len(hits): a facet capped at
					// the page size reads the same for every repository
					// and so says nothing about where to look.
					total, err := repo.Index.SearchCount(in.Q, in.Kind)
					if err != nil {
						log.Printf("search.all count %s: %v", name, err)
						total = len(hits)
					}
					results[i] = answer{repo: name, hits: hits, total: total}
				}()
			}
			wg.Wait()

			var out searchAllOut
			for _, a := range results {
				if a.repo == "" {
					continue
				}
				out.Searched++
				if a.total > 0 {
					out.ByRepo = append(out.ByRepo, repoCount{Repo: a.repo, Count: a.total})
				}
				for _, h := range a.hits {
					out.Results = append(out.Results, searchAllHit{Repo: a.repo, SearchResult: h})
				}
			}
			// Most matches first, so the facet list leads with where the
			// answer probably is; name breaks ties so it is stable.
			sort.Slice(out.ByRepo, func(i, j int) bool {
				if out.ByRepo[i].Count != out.ByRepo[j].Count {
					return out.ByRepo[i].Count > out.ByRepo[j].Count
				}
				return out.ByRepo[i].Repo < out.ByRepo[j].Repo
			})
			// Each index ranks against its OWN corpus statistics, so
			// bm25 scores from different repositories are comparable
			// only approximately. Rank is already baked into each
			// repository's ordering, so the merge preserves it and
			// breaks ties on repo and ref to stay deterministic —
			// honest about being an interleave rather than a true
			// global ranking, which would need fleet-wide term
			// frequencies this deliberately does not keep.
			sort.SliceStable(out.Results, func(i, j int) bool {
				a, b := out.Results[i], out.Results[j]
				if a.Rank != b.Rank {
					return a.Rank < b.Rank
				}
				if a.Repo != b.Repo {
					return a.Repo < b.Repo
				}
				return a.Ref < b.Ref
			})
			if in.Limit > 0 && len(out.Results) > in.Limit {
				out.Results = out.Results[:in.Limit]
			}
			out.Count = len(out.Results)
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[searchIn, searchOut]{
		Name:  "search.query",
		Doc:   "Full-text search over a repository's check-ins, wiki, and tickets.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/search",
		Run: func(ctx context.Context, in searchIn) (searchOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return searchOut{}, err
			}
			results, err := repo.Index.Search(in.Q, in.Kind, in.Limit)
			if err != nil {
				return searchOut{}, err
			}
			return searchOut{Results: results, Count: len(results)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[diffGetIn, diffGetOut]{
		Name:  "checkin.diff",
		Doc:   "Diff one check-in against its primary parent.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/checkin/{hash}/diff",
		Run: func(ctx context.Context, in diffGetIn) (diffGetOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return diffGetOut{}, err
			}
			e, parents, ok, err := repo.Index.CheckinByPrefix(in.Hash)
			if err != nil {
				return diffGetOut{}, err
			}
			if !ok {
				return diffGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound)
			}
			out := diffGetOut{To: e.Hash}
			if len(parents) == 0 {
				// A root check-in has nothing to diff against; its
				// whole file set is the change.
				files, err := repo.Zit.Ls(ctx, e.Hash)
				if err != nil {
					return diffGetOut{}, err
				}
				for _, f := range files {
					out.Files = append(out.Files, zitcli.DiffEntry{Status: "A", Name: f.Name})
				}
				return out, nil
			}
			out.From = parents[0]
			unified, brief, err := repo.Zit.Diff(ctx, out.From, e.Hash)
			if err != nil {
				return diffGetOut{}, err
			}
			out.Unified, out.Files = unified, brief
			out.Structured = diff.Parse(unified)
			diff.Enrich(out.Structured)
			// zit exits 0 with empty output when changed content is
			// unavailable (shunned) — flag it rather than serving what
			// looks like a legitimate no-op diff.
			if out.Unified == "" && len(out.Files) > 0 {
				out.Note = "diff content unavailable — the changed file content may have been shunned from the store"
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[fileGetIn, fileGetOut]{
		Name:  "file.get",
		Doc:   "Fetch one file's content at a check-in.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/file/{hash}/{path...}",
		Run: func(ctx context.Context, in fileGetIn) (fileGetOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return fileGetOut{}, err
			}
			version := d.resolveVersion(repo, in.Hash)
			raw, err := repo.Zit.Cat(ctx, in.Path, version)
			if errors.Is(err, zitcli.ErrNoEntry) {
				// Clean public message; the zit detail stays behind.
				return fileGetOut{}, fmt.Errorf("no file %q at this check-in: %w", in.Path, ops.ErrNotFound)
			}
			if err != nil {
				return fileGetOut{}, err
			}
			out := fileGetOut{Hash: version, Path: in.Path, Size: len(raw)}
			// Binary content stays out of the JSON surface; the page
			// says "binary" and the artifact read verb serves bytes.
			if utf8.Valid(raw) && !bytes.ContainsRune(raw, 0) {
				out.Content = string(raw)
				// Best-effort highlighting; sanitized like all rendered
				// HTML even though quarry generated it.
				if hl, ok := highlight.Code(raw, "", in.Path); ok {
					out.HTML = markup.Sanitize(hl)
				}
			} else {
				out.Binary = true
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[checkinGetIn, checkinGetOut]{
		Name:  "checkin.get",
		Doc:   "Describe one check-in: metadata, parents, and its file set.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/checkin/{hash}",
		Run: func(ctx context.Context, in checkinGetIn) (checkinGetOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return checkinGetOut{}, err
			}
			e, parents, ok, err := repo.Index.CheckinByPrefix(in.Hash)
			if err != nil {
				return checkinGetOut{}, err
			}
			if !ok {
				return checkinGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound)
			}
			entries := []index.TimelineEntry{e}
			if err := repo.Index.AttachCI(entries); err != nil {
				return checkinGetOut{}, err
			}
			out := checkinGetOut{
				Hash: e.Hash, CI: entries[0].CI, Branch: e.Branch, Date: e.Date,
				User: e.User, Comment: e.Comment, Parents: parents,
			}
			// File set is an on-demand zit read (0001 §2), not index
			// state — the index answers "which check-in", the store
			// answers "what does it hold".
			if repo.Zit != nil {
				files, err := repo.Zit.Ls(ctx, e.Hash)
				if err != nil {
					return checkinGetOut{}, err
				}
				out.Files = files
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[wikiHistoryIn, wikiHistoryOut]{
		Name:  "wiki.history",
		Doc:   "List every recorded edit of one wiki page, newest first.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/wiki/{name}/history",
		Run: func(ctx context.Context, in wikiHistoryIn) (wikiHistoryOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return wikiHistoryOut{}, err
			}
			edits, err := repo.Index.WikiHistory(in.Name)
			if err != nil {
				return wikiHistoryOut{}, err
			}
			if len(edits) == 0 {
				return wikiHistoryOut{}, fmt.Errorf("no wiki page %q: %w", in.Name, ops.ErrNotFound)
			}
			return wikiHistoryOut{Title: in.Name, Edits: edits, Count: len(edits)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[wikiGetIn, wikiGetOut]{
		Name:  "wiki.get",
		Doc:   "Fetch one wiki page, rendered — latest, or the revision named by rev.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/wiki/{name}",
		Run: func(ctx context.Context, in wikiGetIn) (wikiGetOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return wikiGetOut{}, err
			}
			page, ok, err := repo.Index.WikiRevision(in.Name, in.Rev)
			if err != nil {
				return wikiGetOut{}, err
			}
			if !ok {
				return wikiGetOut{}, fmt.Errorf("no wiki page %q rev %q: %w", in.Name, in.Rev, ops.ErrNotFound)
			}
			html, err := renderBody(repo.Index, in.Repo, page.Mimetype, page.Body)
			if err != nil {
				return wikiGetOut{}, err
			}
			return wikiGetOut{
				Hash: page.Hash, Title: page.Title, Date: page.Date, User: page.User,
				Mimetype: page.Mimetype, Source: string(page.Body), HTML: html,
				// An empty NEWEST edit is fossil's deletion. The page
				// stays fetchable — its history is real — but a reader
				// must be told this is a tombstone, not a blank page.
				Deleted: in.Rev == "" && len(page.Body) == 0,
			}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[technoteListIn, technoteListOut]{
		Name:  "technote.list",
		Doc:   "List a repository's technotes at their latest edit, newest first.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/technote",
		Run: func(ctx context.Context, in technoteListIn) (technoteListOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return technoteListOut{}, err
			}
			notes, err := repo.Index.Technotes()
			if err != nil {
				return technoteListOut{}, err
			}
			return technoteListOut{Notes: notes, Count: len(notes)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[technoteGetIn, technoteGetOut]{
		Name:  "technote.get",
		Doc:   "Fetch one technote, rendered, by event-id prefix.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/technote/{event}",
		Run: func(ctx context.Context, in technoteGetIn) (technoteGetOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return technoteGetOut{}, err
			}
			n, ok, err := repo.Index.TechnoteByEvent(in.Event)
			if err != nil {
				return technoteGetOut{}, err
			}
			if !ok {
				return technoteGetOut{}, fmt.Errorf("no technote %q: %w", in.Event, ops.ErrNotFound)
			}
			html, err := renderBody(repo.Index, in.Repo, n.Mimetype, []byte(n.Body))
			if err != nil {
				return technoteGetOut{}, err
			}
			return technoteGetOut{Event: n.Event, Date: n.Date, User: n.User,
				Comment: n.Comment, Body: n.Body, HTML: html}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[forumListIn, forumListOut]{
		Name:  "forum.list",
		Doc:   "List a repository's forum threads, most-recently-active first.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/forum",
		Run: func(ctx context.Context, in forumListIn) (forumListOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return forumListOut{}, err
			}
			threads, err := repo.Index.ForumThreads()
			if err != nil {
				return forumListOut{}, err
			}
			return forumListOut{Threads: threads, Count: len(threads)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[forumGetIn, forumGetOut]{
		Name:  "forum.get",
		Doc:   "Fetch one forum thread: every post, rendered, in reading order.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/forum/{root}",
		Run: func(ctx context.Context, in forumGetIn) (forumGetOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return forumGetOut{}, err
			}
			title, posts, ok, err := repo.Index.ForumThreadPosts(in.Root)
			if err != nil {
				return forumGetOut{}, err
			}
			if !ok {
				return forumGetOut{}, fmt.Errorf("no forum thread %q: %w", in.Root, ops.ErrNotFound)
			}
			out := forumGetOut{Root: in.Root, Title: title}
			for _, p := range posts {
				// Dispatch on the post's own N card (absent = Fossil wiki,
				// matching fossil's forum_render).
				html, err := renderBody(repo.Index, in.Repo, p.Mimetype, []byte(p.Body))
				if err != nil {
					return forumGetOut{}, err
				}
				out.Posts = append(out.Posts, forumRendered{
					Hash: p.Hash, Parent: p.Parent, Date: p.Date, User: p.User, HTML: html,
				})
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := registerAuthoring(r, d); err != nil {
		return err
	}
	if d.Chat != nil {
		return registerChat(r, d)
	}
	return nil
}

// registerAuthoring wires the forum and technote write ops (v2 write
// plane, on zit#76's verbs). Fossil has the same surfaces — technoteedit
// and forumedit — so these are the web faces of record types the format
// already carries, not new inventions.
func registerAuthoring(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[forumPostIn, forumPostOut]{
		Name:  "forum.post",
		Doc:   "Open a new forum thread.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/forum",
		Run: func(ctx context.Context, in forumPostIn) (forumPostOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return forumPostOut{}, err
			}
			// Same default as wiki.save and technote.add: quarry's own
			// forms are markdown, and an omitted N card would be read
			// back as text/x-fossil-wiki (fossil's rule), so a post
			// typed as markdown would render as wiki markup.
			mt := in.Mimetype
			if mt == "" {
				mt = authoredMimetype
			}
			var hash string
			if err := repo.write(func() (err error) {
				hash, err = repo.Zit.ForumPost(in.Title, author(ctx), mt, []byte(in.Body))
				return err
			}); err != nil {
				return forumPostOut{}, err
			}
			if err := repo.synced(); err != nil {
				return forumPostOut{}, err
			}
			d.fireNotify(NotifyEvent{
				Workflow: "quarry-activity", To: "repo:" + in.Repo,
				Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
					"summary": "opened forum thread " + in.Title,
					"link":    "/r/" + in.Repo + "/forum/" + hash[:min(16, len(hash))]},
				TxnID: "activity:forum-post:" + hash,
			})
			return forumPostOut{Hash: hash}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[forumReplyIn, forumReplyOut]{
		Name:  "forum.reply",
		Doc:   "Reply to a forum post within its thread.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/forum/{root}",
		Run: func(ctx context.Context, in forumReplyIn) (forumReplyOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return forumReplyOut{}, err
			}
			// The route carries the prefix pages link with; zit wants a
			// post hash it can resolve, so widen it through the index.
			post := in.Root
			if _, posts, ok, perr := repo.Index.ForumThreadPosts(in.Root); perr == nil && ok && len(posts) > 0 {
				post = posts[0].Hash // replying to a thread answers its root
			}
			var hash string
			if err := repo.write(func() (err error) {
				hash, err = repo.Zit.ForumReply(post, author(ctx), replyMimetype(in.Mimetype), []byte(in.Body))
				return err
			}); err != nil {
				if errors.Is(err, zitcli.ErrNoEntry) {
					return forumReplyOut{}, fmt.Errorf("no forum thread %q: %w", in.Root, ops.ErrNotFound)
				}
				return forumReplyOut{}, err
			}
			if err := repo.synced(); err != nil {
				return forumReplyOut{}, err
			}
			d.fireNotify(NotifyEvent{
				Workflow: "quarry-activity", To: "repo:" + in.Repo,
				Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
					"summary": "replied in a forum thread",
					"link":    "/r/" + in.Repo + "/forum/" + in.Root},
				TxnID: "activity:forum-reply:" + hash,
			})
			return forumReplyOut{Hash: hash}, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[technoteAddIn, technoteAddOut]{
		Name:  "technote.add",
		Doc:   "Record a technote; passing an existing event id records an edit.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/technote",
		Run: func(ctx context.Context, in technoteAddIn) (technoteAddOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return technoteAddOut{}, err
			}
			mt := in.Mimetype
			if mt == "" {
				mt = authoredMimetype
			}
			// An edit names an existing technote. Pages link event-id
			// prefixes, so widen to the full id — passing a prefix would
			// mint a NEW technote under that truncated id instead.
			event := in.Event
			if event != "" {
				n, ok, err := repo.Index.TechnoteByEvent(event)
				if err != nil {
					return technoteAddOut{}, err
				}
				if !ok {
					return technoteAddOut{}, fmt.Errorf("no technote %q: %w", in.Event, ops.ErrNotFound)
				}
				event = n.Event
			}
			var id, hash string
			if err := repo.write(func() (err error) {
				id, hash, err = repo.Zit.TechnoteAdd(author(ctx), event, mt, in.Comment, []byte(in.Body))
				return err
			}); err != nil {
				return technoteAddOut{}, err
			}
			if err := repo.synced(); err != nil {
				return technoteAddOut{}, err
			}
			what := "added technote"
			if in.Event != "" {
				what = "edited technote"
			}
			d.fireNotify(NotifyEvent{
				Workflow: "quarry-activity", To: "repo:" + in.Repo,
				Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
					"summary": what,
					"link":    "/r/" + in.Repo + "/technote/" + id[:min(16, len(id))]},
				TxnID: "activity:technote:" + hash,
			})
			return technoteAddOut{Event: id, Hash: hash}, nil
		},
	})
}

// registerChat wires the per-repo live channel ops (0001 §5). Called
// from RegisterAll only when a chat plane is configured.
func registerChat(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[chatHistoryIn, chatHistoryOut]{
		Name:  "chat.history",
		Doc:   "Recent messages in a repository's chat channel, oldest first.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/chat",
		Run: func(ctx context.Context, in chatHistoryIn) (chatHistoryOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return chatHistoryOut{}, err
			}
			limit := in.Limit
			if limit <= 0 {
				limit = 100
			}
			msgs, err := d.Chat.History(repo.Code, limit)
			if err != nil {
				return chatHistoryOut{}, err
			}
			return chatHistoryOut{Messages: msgs, Count: len(msgs)}, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[chatPostIn, chatPostOut]{
		Name: "chat.post",
		Doc:  "Post a message to a repository's chat channel.",
		Kind: ops.Command,
		// A chat post mutates no store (0001 §5: transport is NATS, not
		// the DAG), so it is not lease-gated and stays anonymous-postable
		// in v1; the author is the authenticated user when present.
		Auth:  ops.Anonymous,
		Route: "POST /api/v1/r/{repo}/chat",
		Run: func(ctx context.Context, in chatPostIn) (chatPostOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return chatPostOut{}, err
			}
			from := author(ctx)
			if err := d.Chat.Post(repo.Code, chat.Message{
				Repo: in.Repo, User: from, Text: in.Text,
			}); err != nil {
				return chatPostOut{}, err
			}
			// Mentions route to notify (0001 §5), one trigger per handle.
			for _, who := range mentions(in.Text) {
				d.fireNotify(NotifyEvent{
					Workflow: "quarry-mention", To: who,
					Payload: map[string]any{
						"repo": in.Repo, "from": from, "text": in.Text,
						"link": "/r/" + in.Repo + "/chat",
					},
					TxnID: "mention:" + in.Repo + ":" + who + ":" + from + ":" + in.Text,
				})
			}
			return chatPostOut{OK: true}, nil
		},
	})
}

// renderBody renders an artifact body: markdown/Fossil-wiki through the
// sanitizer-gated markup pipeline (so [[wiki]] and hash refs resolve),
// anything else as escaped text.
func renderBody(ix *index.Index, repo, mimetype string, body []byte) (string, error) {
	// Dispatch exactly as fossil's wiki_render_by_mimetype does: an
	// ABSENT mimetype means Fossil wiki markup, not markdown. Treating
	// it as markdown silently mangles half of every fossil-born corpus
	// (libfossil: 217 of 443 wiki artifacts carry no N card).
	res := resolver{ix: ix, repo: repo}
	switch mimetype {
	case "text/x-markdown", "text/markdown":
		rendered, err := markup.Render(body, res)
		if err != nil {
			return "", err
		}
		return string(rendered), nil
	case "", "text/x-fossil-wiki":
		rendered, err := markup.RenderFossilWiki(body, res)
		if err != nil {
			return "", err
		}
		return string(rendered), nil
	case "text/x-pikchr":
		rendered, err := markup.RenderPikchr(body)
		if err != nil {
			// A diagram that will not compile shows its source and the
			// compiler's complaint, never a broken page.
			return "<pre>" + html.EscapeString(string(body)) + "</pre>" +
				"<p><small>pikchr: " + html.EscapeString(err.Error()) + "</small></p>", nil
		}
		return string(rendered), nil
	default:
		return "<pre>" + html.EscapeString(string(body)) + "</pre>", nil
	}
}

// replyMimetype applies the same N-card default the other authoring ops
// use: an omitted card is text/x-fossil-wiki by fossil's rule, which is
// not what quarry's markdown forms produce.
func replyMimetype(in string) string {
	if in == "" {
		return authoredMimetype
	}
	return in
}

// maxAttachmentBytes caps a single attachment read. quarry buffers the
// blob to answer through the op layer, so this is the memory an
// anonymous request can ask for; larger attachments are refused rather
// than truncated.
const maxAttachmentBytes = 32 << 20

// isReadme matches the names a forge renders under a directory listing.
// Case-insensitive because repositories are inconsistent about it and a
// reader does not care.
func isReadme(name string) bool {
	lower := strings.ToLower(name)
	// The set fossil's browse.c recognizes, minus readme.html — rendering
	// repository HTML is its own decision, tracked separately.
	for _, stem := range []string{"readme.md", "readme.markdown", "readme.wiki", "readme.txt", "readme"} {
		if lower == stem {
			return true
		}
	}
	return false
}

// mimetypeOf maps a filename to the markup dispatch renderBody expects.
// Markup is chosen by EXTENSION, and a name without a recognized one is
// plain text. An earlier version of this guessed markdown for a bare
// README "by convention", which is a GitHub-era convention that predates
// nothing in a Fossil corpus: libspatialite's README is an autotools
// plain-text file whose shell commands are prefixed with the root prompt
// `#`, and markdown reads `# sudo make install` as a heading. Rendering
// it as markdown turned its build instructions into a dozen <h2>s and
// collapsed every hard line break in the command listings.
//
// This is fossil's rule (wiki_render_by_mimetype): known markup types
// render, everything else falls through to escaped <pre class=textPlain>
// with its line structure intact. GitHub does the same — a bare README
// is served as plain text there too.
func mimetypeOf(name string) string {
	switch {
	case hasExt(name, ".md", ".markdown"):
		return "text/x-markdown"
	case hasExt(name, ".wiki"):
		return "text/x-fossil-wiki"
	case hasExt(name, ".pikchr"):
		return "text/x-pikchr"
	default:
		return "text/plain"
	}
}

func hasExt(name string, exts ...string) bool {
	lower := strings.ToLower(name)
	for _, e := range exts {
		if strings.HasSuffix(lower, e) {
			return true
		}
	}
	return false
}

// maxArchiveBytes caps an archive quarry will build. zit writes the
// tarball to a file, so quarry buffers it to answer — this is the memory
// one anonymous request can ask for. A tree beyond it is refused rather
// than half-served.
// searchFanout bounds how many repository indexes a fleet-wide search
// queries at once. Each is a read against its own SQLite file, so the
// limit is about file handles and scheduler pressure rather than
// contention: at 32 repositories the whole fan-out is tens of
// milliseconds and this never engages.
// authoredMimetype is what quarry records for text a person typed into
// one of its own forms. Every authoring box in the web UI is labelled
// Markdown and the API documents the same, so the artifact must say so:
// fossil dispatches on this field and defaults to its own wiki markup
// when it is absent.
const authoredMimetype = "text/x-markdown"

const searchFanout = 16

const maxArchiveBytes = 256 << 20

// tempArchive builds into a throwaway file for the no-cache case, so the
// build function has one shape whether or not a cache is configured.
func tempArchive(build func(dst string) error) (string, error) {
	f, err := os.CreateTemp("", "quarry-archive-*")
	if err != nil {
		return "", err
	}
	path := f.Name()
	f.Close()
	if err := build(path); err != nil {
		os.Remove(path)
		return "", err
	}
	return path, nil
}