Skip to content
quarry Artifact [6c2ff5207a]
Login

Artifact [6c2ff5207a]

Artifact 6c2ff5207a2feb2f33279104d81bf9f817a9e5c2a312013898937b3cfca2b9c1:


// 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"
	"hash/fnv"
	"html"
	"log"
	"math"
	"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/oltp"
	"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)
	// DefaultBranch is the branch a nameless request means, WITH its
	// tip hash. zit's resolution of "trunk" is authoritative (fossil's
	// main-branch setting and tag propagation live there), and the tip
	// must come from the same authority: pinning the name against the
	// index serves a stale tree whenever the index lags the store —
	// which is exactly when it matters. Nil or empty falls back to the
	// index's recency heuristic.
	DefaultBranch func() (name, tip string)
	// 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.
// hotWindow bounds the ranked set: threads quiet longer than this keep
// their recency order below the ranked ones, so ranking stays O(recent)
// no matter how deep the archive grows.
const hotWindow = 30 * 24 * time.Hour

// hotRank is the forum's PUBLISHED ranking — Hacker News' legible decay,
// (score + ½·replies) ÷ (age_hours + 2)^1.8 — over the votes we hold.
// The forum page prints this formula; a change here changes it there.
func hotRank(score, posts int, created string, now time.Time) float64 {
	t, err := time.Parse("2006-01-02T15:04:05", created[:min(19, len(created))])
	if err != nil {
		return 0
	}
	age := now.Sub(t).Hours()
	if age < 0 {
		age = 0
	}
	points := float64(score) + 0.5*float64(posts-1)
	return points / math.Pow(age+2, 1.8)
}

// sortForumHot orders recently-active threads by hotRank, leaving the
// dormant tail in the recency order it arrived in.
func sortForumHot(threads []forumThreadOut, now time.Time) {
	recent := func(t forumThreadOut) bool {
		u, err := time.Parse("2006-01-02T15:04:05", t.Updated[:min(19, len(t.Updated))])
		return err == nil && now.Sub(u) <= hotWindow
	}
	sort.SliceStable(threads, func(i, j int) bool {
		ri, rj := recent(threads[i]), recent(threads[j])
		if ri != rj {
			return ri
		}
		if !ri {
			return false // dormant tail keeps its recency order
		}
		return hotRank(threads[i].Score, threads[i].Posts, threads[i].Created, now) >
			hotRank(threads[j].Score, threads[j].Posts, threads[j].Created, now)
	})
}

// ForumStore is the forum-policy plane, satisfied by *oltp.DB. The
// artifact stays untouched — a category is what THIS forge decided
// about a thread, so a fossil peer sees a plain thread.
type ForumStore interface {
	SetThreadCategory(repo, thread, category string) error
	ThreadCategories(repo string) (map[string]string, error)
	// Poll ballots — the definitions live in post bodies (see poll.go).
	SetPollVote(repo, account, post string, choice int) error
	PollCounts(repo, post string) (map[int]int, error)
	PollChoice(repo, account, post string) (choice int, ok bool)
	// Moderation: the pre-commit gate (approval births the artifact),
	// display-state hiding for synced-in posts, and the append-only
	// public log.
	SetModeration(repo string, enabled bool) error
	ModerationEnabled(repo string) bool
	StagePost(repo, kind, root, title, body, mimetype, author string) (int64, error)
	StagedPending(repo string) ([]oltp.Staged, error)
	StagedByID(repo string, id int64) (oltp.Staged, bool, error)
	ResolveStaged(repo string, id int64, state string) error
	ReopenStaged(repo string, id int64) error
	SetStagedAI(repo string, id int64, action string, score float64, reason string) error
	AppendModLog(repo, actor, action, target, reason string) error
	ModLog(repo string, limit int) ([]oltp.ModEntry, error)
	HidePost(repo, post, reason, actor string) error
	UnhidePost(repo, post string) error
	HiddenPosts(repo string) (map[string]string, error)
	// Subscriptions: explicit unsubscribe outranks every auto-subscribe.
	SetForumSub(repo, account, thread, state string) error
	AutoSubscribe(repo, account, thread string) error
	ForumSubscribers(repo, thread string) ([]string, error)
	ForumSubState(repo, account, thread string) string
}

// announcements is the built-in maintainer-only category (oltp.Announcements).
const announcements = "announcements"

// VoteStore is the forum-vote plane, satisfied by *oltp.DB. Scores are
// derived from vote rows on every read — no counter exists to drift.
type VoteStore interface {
	SetForumVote(repo, account, post, thread string, dir int) error
	ForumPostScores(repo string, posts []string) (map[string]int, error)
	ForumThreadScores(repo string, threads []string) (map[string]int, error)
	ForumAccountVotes(repo, account string, posts []string) (map[string]int, error)
}

// ViewStore holds named tracker reports. nil disables the surface —
// a forge without an OLTP simply has no saved views, and the listings
// still work, because a saved view was only ever a name for a URL.
type ViewStore interface {
	SaveView(account, repo, name, query string) error
	DeleteView(account, repo, name string) error
	SavedViews(account, repo string) ([]oltp.SavedView, error)
}

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
	// Votes is forum vote state (forge-local, 0001 §8 OLTP — never part
	// of the DAG or sync). nil disables the vote op and score fields,
	// which is how a forge without an OLTP simply has no votes.
	Votes VoteStore
	// Forum is forum policy state: thread categorization. Same rules as
	// Votes — forge-local, nil disables the surfaces that need it.
	Forum ForumStore
	// Views holds each account's named reports. Same rules again.
	Views ViewStore
	// TrustedAuthor reports whether an account bypasses the moderation
	// gate on a repo (site admins and role-holders, per forge policy).
	// nil trusts everyone — the no-accounts forge has no strangers.
	TrustedAuthor func(account, repo string) bool
	// ForumHook is the optional AI-moderation verdict source, called
	// asynchronously for each staged submission. nil means no hook.
	ForumHook func(ctx context.Context, staged oltp.Staged) (HookVerdict, error)
	// 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
	// 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
	// RepoMeta supplies the settings-held About fields per repository.
	// Like Descriptions, nil means "this node has no registry".
	RepoMeta func() map[string]RepoMeta
	// 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"`
}

// defaultBranchOf prefers zit's answer — name AND tip — falling back
// to the index's recency heuristic when the seam is absent (tests,
// torn-down repos). An empty tip means "resolve it yourself".
func defaultBranchOf(repo *Repo) (name, tip string) {
	if repo.DefaultBranch != nil {
		if b, t := repo.DefaultBranch(); b != "" {
			return b, t
		}
	}
	if repo.Index != nil {
		name = repo.Index.DefaultBranch()
		if b, ok, err := repo.Index.TipOf(name); err == nil && ok {
			tip = b.Tip
		}
		return name, tip
	}
	return "trunk", ""
}

// RepoMeta is the About-panel settings a maintainer edits in place.
type RepoMeta struct {
	Homepage string
	Topics   []string
}

// 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, homepage, topics 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
	// DetachMirror stops tracking and keeps the upstream as provenance
	// (DetachedFrom); SetMirrorWrites lets a mirror take local writes.
	DetachMirror(repo string) error
	SetMirrorWrites(repo string, accept bool) 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"`
	// AcceptWrites: local pushes and tickets are allowed (a fork that
	// still tracks upstream). DetachedFrom: the former upstream of a
	// store that was detached — Enabled is false and the store is
	// native; this is provenance.
	AcceptWrites bool   `json:"acceptWrites"`
	DetachedFrom string `json:"detachedFrom,omitempty"`
	// State is the freshness verdict: current, lagging, failing, or
	// detached. One derivation, here, so every surface agrees.
	State string `json:"state"`
}

// Mirror freshness states.
const (
	MirrorCurrent  = "current"  // last success within 2× the interval
	MirrorLagging  = "lagging"  // older than that, but no error recorded
	MirrorFailing  = "failing"  // the last attempt failed
	MirrorDetached = "detached" // no longer tracking; DetachedFrom set
)

// mirrorState derives the freshness verdict at now. A mirror that has
// never pulled is lagging, not failing: nothing has gone wrong yet.
func mirrorState(m Mirror, now time.Time) string {
	switch {
	case !m.Enabled:
		return MirrorDetached
	case m.Failures > 0:
		return MirrorFailing
	case m.LastOK == "":
		return MirrorLagging
	}
	ok, err := time.Parse(time.RFC3339, m.LastOK)
	if err != nil || now.Sub(ok) > 2*time.Duration(m.Every)*time.Second {
		return MirrorLagging
	}
	return MirrorCurrent
}

// RepoMirror is a repository's posture as the repo model carries it.
// Tracking is false on a native store and the rest is zero. The
// repository is the thing every page and API call already has in
// hand; the mirror table is where the posture is stored, not where it
// should have to be looked up from. (A value, not a pointer: the op
// schema generator describes values.)
type RepoMirror struct {
	Tracking     bool   `json:"tracking"`
	URL          string `json:"url,omitempty"`
	Every        int    `json:"every"`
	LastOK       string `json:"lastOk"`
	LastAt       string `json:"lastAt"`
	LastErr      string `json:"lastErr,omitempty"`
	Failures     int    `json:"failures"`
	AcceptWrites bool   `json:"acceptWrites"`
	State        string `json:"state"`
}

// posture reports a repository's mirror (Tracking false when native)
// and, for a store that was detached, its former upstream.
func (d Deps) posture(repo string) (RepoMirror, string) {
	if d.Mirrors == nil {
		return RepoMirror{}, ""
	}
	m, ok, err := d.Mirrors.MirrorFor(repo)
	if err != nil || !ok {
		return RepoMirror{}, ""
	}
	if !m.Enabled {
		return RepoMirror{}, m.DetachedFrom
	}
	return RepoMirror{
		Tracking: true, URL: m.URL, Every: m.Every, LastOK: m.LastOK, LastAt: m.LastAt,
		LastErr: m.LastErr, Failures: m.Failures, AcceptWrites: m.AcceptWrites,
		State: mirrorState(m, time.Now()),
	}, m.DetachedFrom
}

// refuseUnlessWritable is the write ops' gate: nil on a native store or
// a mirror that accepts writes; otherwise a forbidden error that names
// the upstream, which is where the write belongs.
func (d Deps) refuseUnlessWritable(repo string) error {
	ok, upstream := d.Writable(repo)
	if ok {
		return nil
	}
	return fmt.Errorf("%w: %s mirrors %s and does not accept local writes", ops.ErrForbidden, repo, upstream)
}

// Writable reports whether local writes (push, tickets, wiki, forum)
// are accepted: always on a native store, on a mirror only when the
// operator allowed them. The string names the upstream for the
// refusal message.
func (d Deps) Writable(repo string) (bool, string) {
	m, _ := d.posture(repo)
	if !m.Tracking || m.AcceptWrites {
		return true, ""
	}
	return false, m.URL
}

// 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.
// docMimetype maps a FILE EXTENSION to the renderer that owns it, which
// is how fossil decides what an embedded document is (see its
// embeddeddoc.wiki): a repository's own documentation lives in the tree
// as .md and .wiki files, not as wiki artifacts, and without this it can
// only be read as source. The empty string means Fossil wiki, matching
// renderBody's dispatch and fossil's own null-mimetype rule.
//
// An extension not listed here is not a document. It is returned
// unrenderable rather than guessed at, so a .c file stays a .c file.
func docMimetype(path string) (string, bool) {
	dot := strings.LastIndex(path, ".")
	if dot < 0 {
		return "", false
	}
	switch strings.ToLower(path[dot+1:]) {
	case "wiki":
		return "", true
	case "md", "markdown":
		return "text/x-markdown", true
	case "html", "htm":
		return "text/html", true
	case "txt":
		return "text/plain", true
	case "pikchr":
		return "text/x-pikchr", true
	}
	return "", false
}

// resolveCheckin is resolveVersion plus the one thing it deliberately
// refuses to do: turn a NAME into a check-in hash. zit is the authority
// on names (0001 §2) — the index's branch table answers "trunk" with a
// branch literally called trunk, which is a stale lie on any repository
// that renamed its default — so the name goes to zit and nowhere else.
//
// This is the fix resolveVersion's own comment asks for: a page that
// prints a version as a check-in link has to be printing a check-in.
// Only a symbolic ref pays for the extra call; a ref the index already
// resolved to a full hash is returned untouched.
func (d Deps) resolveCheckin(ctx context.Context, repo *Repo, ref string) string {
	version := d.resolveVersion(repo, ref)
	if repo.Zit == nil || isCheckinHash(version) {
		return version
	}
	if hash, _, err := repo.Zit.Info(ctx, version); err == nil && isCheckinHash(hash) {
		return hash
	}
	// Unresolvable stays as written: the content still serves, and a
	// name shown as a name is better than a name shown as a hash.
	return version
}

// isCheckinHash reports whether ref is already a full artifact id
// rather than something zit still has to look up.
func isCheckinHash(ref string) bool {
	if len(ref) != 64 {
		return false
	}
	for i := 0; i < len(ref); i++ {
		c := ref[i]
		if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
			return false
		}
	}
	return true
}

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"`
	// Kind narrows by posture: mirror (tracking an upstream) or
	// native (quarry is the source of truth). Empty lists both.
	Kind string `json:"kind" validate:"oneof=|mirror|native"`
}

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"`
	// Mirror is the posture: nil on a native store. DetachedFrom is the
	// former upstream of a store that once mirrored and was detached.
	Mirror       RepoMirror `json:"mirror"`
	DetachedFrom string     `json:"detachedFrom,omitempty"`
	// 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"`
	// Homepage and Topics are the settings-held About fields, for the
	// settings form to display and the panel to overlay.
	Homepage string `json:"homepage,omitempty"`
	Topics   string `json:"topics,omitempty"`
	// 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"`
	LatestTagTip  string `json:"latestTagTip,omitempty"`
	LatestTagDate string `json:"latestTagDate,omitempty"`
	TagCount      int    `json:"tagCount,omitempty"`
	Contributors  int    `json:"contributors"`
	// LicenseName is the detected license ("MIT", "Apache-2.0"); empty
	// when the repo has no license document or the text is not one of
	// the well-known licenses.
	LicenseName string `json:"licenseName,omitempty"`
	// The newest announcement thread — the forum's maintainer-only
	// category, surfaced on the repo home for top-level visibility.
	AnnounceRoot  string `json:"announceRoot,omitempty"`
	AnnounceTitle string `json:"announceTitle,omitempty"`
	AnnounceDate  string `json:"announceDate,omitempty"`
	Problem       string `json:"problem,omitempty"`
}

// licenseSignatures maps a phrase that appears in exactly one
// well-known license text to its SPDX-style name, checked in order.
// GitHub's sidebar says "Apache-2.0 license" where a marker file alone
// can only say "License"; the phrase test recovers that at zero cost
// beyond the memoized file read. No match is not a failure — plenty of
// real licenses (tcl's own terms) are bespoke.
var licenseSignatures = []struct{ phrase, name string }{
	{"Apache License", "Apache-2.0"},
	{"GNU AFFERO GENERAL PUBLIC LICENSE", "AGPL-3.0"},
	{"GNU LESSER GENERAL PUBLIC LICENSE", "LGPL"},
	{"GNU LIBRARY GENERAL PUBLIC LICENSE", "LGPL-2.0"},
	{"GNU GENERAL PUBLIC LICENSE", "GPL"},
	{"Mozilla Public License, v. 2.0", "MPL-2.0"},
	{"Mozilla Public License Version 2.0", "MPL-2.0"},
	{"Permission to use, copy, modify, and/or distribute this software", "ISC"},
	{"Redistributions of source code must retain", "BSD"},
	{"Permission is hereby granted, free of charge", "MIT"},
	{"This is free and unencumbered software released into the public domain", "Unlicense"},
	{"CC0 1.0 Universal", "CC0-1.0"},
}

func detectLicense(text string) string {
	head := text
	if len(head) > 4096 {
		head = head[:4096]
	}
	for _, sig := range licenseSignatures {
		if strings.Contains(head, sig.phrase) {
			return sig.name
		}
	}
	return ""
}

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

// myRepos scopes a cross-repository page to what the caller may see.
// Explicit grants are OWNERSHIP and outrank the admin flag: running the
// forge does not make every corpus repo yours. The fleet is the
// fallback only so an admin with nothing granted yet still gets a page
// rather than empty panes. The caller comes from the context, never
// from an input field, so nobody asks for someone else's grants.
func (d Deps) myRepos(ctx context.Context, limit int) (string, []string, error) {
	user := ops.User(ctx)
	if user == "" || d.Accounts == nil {
		return "", nil, fmt.Errorf("no signed-in account: %w", ops.ErrNotFound)
	}
	grants, err := d.Accounts.GrantsFor(user)
	if err != nil {
		return "", nil, err
	}
	var names []string
	for repo := range grants {
		if repo != "" {
			names = append(names, repo)
		}
	}
	sort.Strings(names)
	if _, site := grants[""]; site && len(names) == 0 {
		names = d.Repos.Names()
	}
	if len(names) > limit {
		names = names[:limit]
	}
	return user, names, nil
}

// meFeedIn narrows the dashboard. Both fields are ways of LOOKING at
// the same bounded window, never ways of reaching further into it.
type meFeedIn struct {
	// Kind keeps one sort of event: ci, t, w, e, f.
	Kind string `json:"kind" validate:"max=2"`
	// Find filters the repository list by substring, case-folded — the
	// find-a-repository box, which works with no script.
	Find string `json:"find" validate:"max=64"`
}

// 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"`
	// Open is the tracker's open work. A landing page that lists your
	// repositories without saying what is waiting in them has told you
	// where you could go, not where you should.
	Open int `json:"open"`
}

// kindCount is one sort of event with its share of the window below.
// The count is of the SAME rows the chip filters to, so the number and
// what clicking it gives you can never disagree.
type kindCount struct {
	Kind  string `json:"kind"`
	Count int    `json:"count"`
}

// ticketAcrossIn is ticket.list's report without the repository: the
// same axes asked of every tracker the caller can reach at once. There
// is no cursor, because "the next fifty by updated date" has no meaning
// once the rows come from K trackers that were merged in memory — the
// listing is bounded and says so instead of pretending to paginate.
type ticketAcrossIn struct {
	Limit int    `json:"limit" validate:"min=0,max=200,default=50"`
	State string `json:"state" validate:"oneof=open|closed|all"`
	// Repo narrows to one tracker. It is a facet like any other here,
	// not the addressing that ticket.list needs.
	Repo      string `json:"repo" validate:"max=64"`
	Type      string `json:"type" validate:"max=64"`
	Severity  string `json:"severity" validate:"max=64"`
	Priority  string `json:"priority" validate:"max=64"`
	Subsystem string `json:"subsystem" validate:"max=64"`
	Status    string `json:"status" validate:"max=64"`
	Author    string `json:"author" validate:"max=64"`
	Title     string `json:"title" validate:"max=128"`
	Order     string `json:"order" validate:"max=32"`
	// Awaiting asks the one question a landing page exists to answer:
	// which of the caller's open tickets is the ball in their court on.
	// That is derivable and needs no stored "last seen" — a ticket the
	// caller opened whose most recent change was somebody else's is
	// waiting on them. It forces author and state, so it cannot be bent
	// into a report that means something else.
	Awaiting bool `json:"awaiting"`
}

// mergedFacets turns summed per-tracker counts back into the browse
// order every facet list uses: most open work first, then by name so
// two trackers with equal counts cannot swap places between renders.
func mergedFacets(counts map[string]int) []index.TicketFacet {
	var out []index.TicketFacet
	for v, n := range counts {
		out = append(out, index.TicketFacet{Value: v, Open: n})
	}
	sort.SliceStable(out, func(i, j int) bool {
		if out[i].Open != out[j].Open {
			return out[i].Open > out[j].Open
		}
		return strings.ToLower(out[i].Value) < strings.ToLower(out[j].Value)
	})
	return out
}

// acrossTicket is a list row that knows which tracker it came from.
type acrossTicket struct {
	Repo string `json:"repo"`
	ticketSummary
}

type ticketAcrossOut struct {
	Tickets []acrossTicket `json:"tickets"`
	Count   int            `json:"count"`
	// Open and Closed count every reachable tracker through this same
	// filter, so the header describes the listing and not the corpus.
	Open   int `json:"open"`
	Closed int `json:"closed"`
	// The browse axes, merged across trackers. Repos is the axis that
	// only exists here: where the work is, by tracker.
	Repos      []index.TicketFacet `json:"repos,omitempty"`
	Subsystems []index.TicketFacet `json:"subsystems,omitempty"`
	Authors    []index.TicketFacet `json:"authors,omitempty"`
	// Truncated says the merge hit its bound: there is more work than
	// this listing shows, and no cursor will reach it.
	Truncated bool `json:"truncated"`
}

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"`
	// Kinds is what the window holds, by kind, for the feed's chips.
	Kinds []kindCount `json:"kinds,omitempty"`
	// 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"`
	// Description REPLACES the ticket's body (fossil's "comment"
	// column); Comment appends a remark. Both may travel in one change.
	Description string `json:"description" 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 {
	// IndexBehind: the index has not folded the store's own tip, so
	// history-derived numbers and listings on the page may be
	// incomplete. Surfaces show it; they never silently omit.
	IndexBehind  bool `json:"indexBehind,omitempty"`
	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"`
	// Homepage must be http(s) or empty; Topics is space-separated,
	// at most twenty — the same rules .quarry/repo.json enforces.
	Homepage string `json:"homepage" validate:"max=300"`
	Topics   string `json:"topics" validate:"max=600"`
}

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

// 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 mirrorDetachIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

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

type mirrorWritesIn struct {
	Repo   string `json:"repo" validate:"required,max=64"`
	Accept bool   `json:"accept"`
}

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

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"`
	// DefaultBranch is the repository's mainline, so a renderer can
	// give it the leftmost rail whatever order commits arrive in.
	DefaultBranch string `json:"defaultBranch,omitempty"`
	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"`
	// IndexBehind reports that the index has not folded up to the
	// store's own default tip — a rebuild in progress. The entries are
	// real but the recent end is MISSING, and a reader must be told:
	// a timeline that silently stops three years ago reads as "this
	// project died", which is a worse lie than "we're rebuilding".
	IndexBehind bool `json:"indexBehind,omitempty"`
}

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

// wikiStructureFloor is where "what is unreachable" starts being a
// question. Below it every page is one click from every other and the
// answer is furniture.
const wikiStructureFloor = 5

type wikiListOut struct {
	Pages []index.WikiTitle `json:"pages"`
	Count int               `json:"count"`
	// Orphans and Wanted are what a page listing cannot tell you: which
	// pages nothing links to, and which links go nowhere. Both are
	// derived from the live pages, so they cost no storage and cannot
	// go stale. Omitted when the wiki is too small for either to be a
	// question worth asking.
	Orphans []string           `json:"orphans,omitempty"`
	Wanted  []index.WantedPage `json:"wanted,omitempty"`
	// Home is the page a reader starts on, when there is one.
	Home string `json:"home,omitempty"`
}

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"`
	// Outline is the page's headings, in order, addressing the anchors
	// carried by HTML. Derived from the RENDERED output rather than from
	// either parser, because Fossil wiki passes hand-written headings
	// through with no id and markdown does not — see markup.Outline.
	Outline []markup.Heading `json:"outline,omitempty"`
	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"`
	// Sort is "hot" for ranked order; anything else is most-recent-first.
	Sort string `json:"sort" validate:"max=8"`
}

// forumThreadOut is one listed thread plus its forge-local vote score.
// The score lives beside the index row rather than in it: the index is
// rebuilt from the DAG and must not carry OLTP state.
type forumThreadOut struct {
	index.ForumThread
	Score    int    `json:"score"`
	Category string `json:"category,omitempty"`
	// Spark is the thread's reply activity over its lifetime, binned to
	// levels 0-4 — the list draws it as a tiny bar-run. Dormant marks a
	// thread quiet past the hot window, for muted rendering.
	Spark   []int `json:"spark,omitempty"`
	Dormant bool  `json:"dormant"`
}

// sparkBins buckets the thread's post dates across its lifetime into n
// bins, normalized to levels 0-4. Under three posts there is no shape
// worth drawing.
func sparkBins(dates []string, n int) []int {
	if len(dates) < 3 {
		return nil
	}
	parse := func(d string) (time.Time, bool) {
		if len(d) > 19 {
			d = d[:19]
		}
		t, err := time.Parse("2006-01-02T15:04:05", d)
		return t, err == nil
	}
	first, ok1 := parse(dates[0])
	last, ok2 := parse(dates[len(dates)-1])
	if !ok1 || !ok2 || !last.After(first) {
		return nil
	}
	span := last.Sub(first)
	counts := make([]int, n)
	for _, d := range dates {
		t, ok := parse(d)
		if !ok {
			continue
		}
		i := int(float64(n-1) * float64(t.Sub(first)) / float64(span))
		if i < 0 {
			i = 0
		}
		if i > n-1 {
			i = n - 1
		}
		counts[i]++
	}
	max := 0
	for _, c := range counts {
		if c > max {
			max = c
		}
	}
	out := make([]int, n)
	for i, c := range counts {
		if c > 0 {
			out[i] = 1 + c*3/max
		}
	}
	return out
}

type forumListOut struct {
	Threads []forumThreadOut `json:"threads"`
	// Announcements is the pinned strip: the maintainer-only category,
	// newest first, kept out of Threads so pages never show one thread
	// twice.
	Announcements []forumThreadOut `json:"announcements,omitempty"`
	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"`
	// Score is the post's net vote; YourVote is the calling account's
	// own vote (-1, 0, 1), so the page can light the cast arrow.
	Score    int `json:"score"`
	YourVote int `json:"yourVote"`
	// Hidden collapses the post behind its public reason — display
	// state on a synced-in artifact, disclosed, never deleted.
	Hidden       bool   `json:"hidden"`
	HiddenReason string `json:"hiddenReason,omitempty"`
	// Poll carries the widget when the post body holds a well-formed
	// ```poll block (Present true); the block is then omitted from HTML.
	Poll pollRendered `json:"poll"`
}

type pollRendered struct {
	Present  bool     `json:"present"`
	Question string   `json:"question"`
	Options  []string `json:"options"`
	Counts   []int    `json:"counts"`
	Percents []int    `json:"percents"`
	Total    int      `json:"total"`
	// YourChoice is the calling account's ballot; -1 when none.
	YourChoice int    `json:"yourChoice"`
	Closed     bool   `json:"closed"`
	Closes     string `json:"closes,omitempty"`
}

type forumGetOut struct {
	Root  string `json:"root"`
	Title string `json:"title"`
	// Excerpt is the opener's first prose line, for link unfurls.
	Excerpt string          `json:"excerpt,omitempty"`
	Posts   []forumRendered `json:"posts"`
	// YourSub is the calling account's standing on this thread:
	// 'sub', 'unsub', or '' (no explicit choice yet).
	YourSub string `json:"yourSub"`
}

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"`
	// Staged reports the submission was HELD for moderation: no
	// artifact exists yet, so there is no hash to link.
	Staged bool `json:"staged"`
}

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"`
	// To names the specific post being answered (hash prefix); empty
	// answers the thread root. Fossil's reply DAG, kept.
	To       string `json:"to" validate:"max=64"`
	Body     string `json:"body" validate:"required,max=1048576"`
	Mimetype string `json:"mimetype" validate:"max=64"`
}

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

type markupPreviewIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Body string `json:"body" validate:"max=1048576"`
}

type markupPreviewOut struct {
	HTML string `json:"html"`
}

type forumVoteIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Post string `json:"post" validate:"required,min=4,max=64"`
	// Dir is the vote: up, down, or none (retract).
	Dir string `json:"dir" validate:"oneof=up|down|none"`
}

type forumVoteOut struct {
	Post  string `json:"post"`
	Score int    `json:"score"`
}

type pollVoteIn struct {
	Repo   string `json:"repo" validate:"required,max=64"`
	Post   string `json:"post" validate:"required,min=4,max=64"`
	Choice int    `json:"choice" validate:"min=0,max=63"`
}

type pollVoteOut struct {
	Post   string `json:"post"`
	Choice int    `json:"choice"`
}

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"`
}

// docDirs are where a project's documentation conventionally lives.
// Deliberately NOT the whole tree: every third directory holds a
// README, and a documentation index that lists all of them is a file
// listing wearing a different hat. www/ is fossil's own convention,
// doc/ and docs/ everyone else's.
var docDirs = []string{"www/", "doc/", "docs/"}

// docIndexNames are the files a DIRECTORY serves, in preference order.
// Fossil's own documentation links directories with a trailing slash —
// www/index.wiki carries [./server/|a variety of techniques] — and a
// directory that answers nothing turns every one of those into a 404.
// Markdown and wiki lead because they render into the page; .html is
// the older convention and still honoured.
var docIndexNames = []string{"index.md", "index.wiki", "index.markdown",
	"index.html", "index.htm", "index.txt"}

// docIndexOf finds the document a directory should serve, if it has one.
// One listing under the directory rather than a probe per candidate: a
// directory request should not cost six subprocesses.
func docIndexOf(ctx context.Context, repo *Repo, version, dir string) (string, bool) {
	dir = strings.TrimSuffix(dir, "/")
	if dir == "" || repo.Zit == nil {
		return "", false
	}
	under, err := repo.Zit.LsUnder(ctx, version, dir)
	if err != nil {
		return "", false
	}
	have := make(map[string]bool, len(under))
	for _, f := range under {
		have[f.Name] = true
	}
	for _, name := range docIndexNames {
		if candidate := dir + "/" + name; have[candidate] {
			return candidate, true
		}
	}
	return "", false
}

// isDocPath reports whether a tree path belongs in the documentation
// index: renderable, and either in a documentation directory or at the
// top level, where README and CHANGES live.
func isDocPath(path string) bool {
	if _, ok := docMimetype(path); !ok {
		return false
	}
	for _, d := range docDirs {
		if strings.HasPrefix(path, d) {
			return true
		}
	}
	return !strings.Contains(path, "/")
}

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

// docEntry is one document in the index.
type docEntry struct {
	Path string `json:"path"`
	Size int64  `json:"size"`
	// Date is the newest check-in that touched it, so a reader can tell
	// living documentation from a file nobody has opened in six years;
	// User is who made that change. A document in the tree has an author
	// exactly as a wiki page does — it is just recorded in the check-in
	// rather than on the artifact.
	Date string `json:"date,omitempty"`
	User string `json:"user,omitempty"`
}

type docListOut struct {
	Version string     `json:"version"`
	Docs    []docEntry `json:"docs"`
}

type docGetIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Hash string `json:"hash" validate:"required,min=1,max=64"`
	Path string `json:"path" validate:"required,max=512"`
}
type docGetOut struct {
	Hash string `json:"hash"`
	Path string `json:"path"`
	Size int    `json:"size"`
	// Mimetype is the renderer chosen from the extension; empty means
	// Fossil wiki, as everywhere else in this file.
	Mimetype string `json:"mimetype"`
	HTML     string `json:"html"`
	// Outline addresses the anchors HTML carries, so a long document
	// gets the same contents rail a wiki page does.
	Outline []markup.Heading `json:"outline,omitempty"`
	// ContentType and Content carry an image, which is delivered rather
	// than rendered. base64 for the same reason attachments are: bytes
	// do not belong in a JSON string.
	ContentType string `json:"contentType,omitempty"`
	Content     string `json:"content,omitempty"`
}

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
	// Renderable says this path also has a rendered form at doc.get.
	// Answered HERE so the two views cannot disagree about which
	// extensions are documents — the list lives in docMimetype alone.
	Renderable bool `json:"renderable,omitempty"`
}

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"`
	// The newest check-in that touched this entry — GitHub's middle
	// column. Empty when the index has no filechange rows yet (a
	// repository indexed before that fold existed).
	LastHash    string `json:"lastHash,omitempty"`
	LastDate    string `json:"lastDate,omitempty"`
	LastComment string `json:"lastComment,omitempty"`
}

type treeOut struct {
	Version string `json:"version"`
	// Ref is the branch name this listing answered for — the request's
	// own, or the repository's default when the request named none.
	Ref      string      `json:"ref,omitempty"`
	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 resolveIn struct {
	Repo   string `json:"repo" validate:"required,max=64"`
	Prefix string `json:"prefix" validate:"required,min=4,max=64"`
}

type resolveOut struct {
	Kind string `json:"kind"` // ci | t | w | e | f
	Ref  string `json:"ref"`  // hash, ticket id, wiki title, event id, thread root
}

type diffGetIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Hash string `json:"hash" validate:"required,min=4,max=64"`
	// File restricts Structured to one path. Offset and MaxLines pick a
	// window of structured lines (MaxLines 0 = to the end); what is cut
	// is reported per file as before/omitted and in total as
	// omittedLines, with nextOffset continuing the window. Unified is never
	// cut — a script reads that, a page reads Structured.
	File     string `json:"file,omitempty" validate:"max=4096"`
	Offset   int    `json:"offset,omitempty" validate:"min=0"`
	MaxLines int    `json:"maxLines,omitempty" validate:"min=0"`
}

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
	// OmittedLines is how many structured lines follow the window;
	// NextOffset is the offset that continues from it.
	OmittedLines int `json:"omittedLines,omitempty"`
	NextOffset   int `json:"nextOffset,omitempty"`
	TotalLines   int `json:"totalLines,omitempty"` // structured lines in the whole (narrowed) diff
	LastOffset   int `json:"lastOffset,omitempty"` // where the final window begins
	Offset       int `json:"offset,omitempty"`     // where this window begins (the request's offset, snapped to a hunk)
}

type checkinGetIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Hash string `json:"hash" validate:"required,min=4,max=64"`
	// Files=0 skips the manifest read — the check-in PAGE renders the
	// diff, not the tree, and was paying a zit ls it threw away.
	Files string `json:"files" validate:"max=4"`
}

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"`
	// DiscussedIn is the forum threads whose posts reference this
	// check-in — derived anchors, resolved against this hash at read.
	DiscussedIn []index.ForumThread `json:"discussedIn,omitempty"`
}

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"`
	// State selects which lives are listed: open (the default), closed,
	// or all. The default is the open ones: a tracker list is a working
	// surface, and across this corpus half of every tracker is closed.
	State string `json:"state" validate:"oneof=open|closed|all"`
	// The report axes. Each is an exact match on the folded field, and
	// together they ARE the saved report: the URL that produced a
	// listing reproduces it, which is the whole mechanism behind
	// shareable reports and the reason none of this needs new queries.
	Type      string `json:"type" validate:"max=64"`
	Severity  string `json:"severity" validate:"max=64"`
	Priority  string `json:"priority" validate:"max=64"`
	Subsystem string `json:"subsystem" validate:"max=64"`
	Status    string `json:"status" validate:"max=64"`
	Author    string `json:"author" validate:"max=64"`
	// Title narrows to tickets whose title contains it, case-folded.
	Title string `json:"title" validate:"max=128"`
	// Offset skips rows before this page. For the default order prefer
	// Before, which is stable while the tracker changes underneath.
	Offset int `json:"offset" validate:"min=0,max=100000"`
	// Order names one of index.TicketOrders. Anything else sorts by the
	// default, so a stale link degrades instead of erroring.
	Order string `json:"order" validate:"max=32"`
}

// 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"`
	Author     string `json:"author,omitempty"`
	LastUser   string `json:"lastUser,omitempty"`
	Comments   int    `json:"comments"`
}

type ticketListOut struct {
	Tickets []ticketSummary `json:"tickets"`
	Count   int             `json:"count"`
	// Open and Closed count the whole FILTERED tracker, not this page.
	Open   int `json:"open"`
	Closed int `json:"closed"`
	// The values these two fields actually hold, with the open work
	// under each: no vocabulary can predict a project's subsystems or
	// who showed up to file, and a browse list needs the counts.
	Subsystems []index.TicketFacet `json:"subsystems,omitempty"`
	Authors    []index.TicketFacet `json:"authors,omitempty"`
	// Next is the `before` cursor for the following (older-updated)
	// page; empty when this page is the last.
	Next string `json:"next"`
}

// A saved view belongs to one listing and one account. Repo is the
// tracker it was saved against, empty for the cross-repository listing:
// a report naming a subsystem means nothing on a tracker that has no
// such subsystem, so the two never appear in each other's rail.
type viewListIn struct {
	Repo string `json:"repo" validate:"max=64"`
}

type viewListOut struct {
	Views []oltp.SavedView `json:"views"`
}

type viewSaveIn struct {
	Repo  string `json:"repo" validate:"max=64"`
	Name  string `json:"name" validate:"required,max=48"`
	Query string `json:"query" validate:"max=512"`
}

type viewSaveOut struct {
	Name  string `json:"name"`
	Query string `json:"query"`
}

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

type viewDeleteOut struct {
	Deleted string `json:"deleted"`
}

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
}

// docResolver renders a file from the tree, where a relative target is a
// sibling document. The href is left RELATIVE and the browser resolves
// it against the page's own URL — which is why the doc route mirrors the
// tree path. Nothing here has to know the current directory, and
// ./x.wiki, ../y.txt and ./z.wiki#frag all come out right for free.
type docResolver struct{ resolver }

func (docResolver) DocRelative(target string) string { return target }

// docDirect names the file types served as BYTES rather than rendered,
// matching the list in fossil's embeddeddoc.wiki. SVG is deliberately
// absent: it is a document that can carry script, and serving one from
// a stranger's repository under our own origin is an XSS, not an image.
func docDirect(path string) (string, bool) {
	dot := strings.LastIndex(path, ".")
	if dot < 0 {
		return "", false
	}
	switch strings.ToLower(path[dot+1:]) {
	case "gif":
		return "image/gif", true
	case "jpg", "jpeg":
		return "image/jpeg", true
	case "png":
		return "image/png", true
	}
	return "", false
}

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
	}
	// A forum post: land on its thread, anchored to the exact post.
	if post, thread, ok, err := r.ix.ForumPostThread(prefix); err == nil && ok {
		return "/r/" + r.repo + "/forum/" + thread[:min(16, len(thread))] + "#p-" + post[:min(12, len(post))]
	}
	return ""
}

// DescribeHash gives a resolvable check-in reference its comment's
// first line — rendered links carry it as hover title and excerpt.
// Only check-ins: a ticket link already reads as its id, and a forum
// link's thread title is one click away.
func (r resolver) DescribeHash(prefix string) string {
	if r.ix == nil {
		return ""
	}
	if e, _, ok, err := r.ix.CheckinByPrefix(prefix); err == nil && ok {
		first, _, _ := strings.Cut(strings.TrimSpace(e.Comment), "\n")
		return first
	}
	return ""
}

// ResolveUser upgrades @mentions: a login known to this repository's
// contributor fold links to their person page, in their hue.
func (r resolver) ResolveUser(name string) (string, int, bool) {
	if r.ix == nil || !r.ix.HasContributor(name) {
		return "", 0, false
	}
	return "/people/" + url.PathEscape(name), userHue(name), true
}

// userHue mirrors the web layer's derivation (fnv64 % 360): one hue is
// one person across every surface, so the two must never diverge.
func userHue(name string) int {
	h := fnv.New64a()
	h.Write([]byte(name))
	return int(h.Sum64() % 360)
}

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",
		Caps:  "kf",
		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) {
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return wikiSaveOut{}, err
			}
			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",
		Caps:  "n",
		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) {
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return ticketNewOut{}, err
			}
			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",
		Caps:  "cw",
		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) {
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return ticketSetOut{}, err
			}
			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 in.Description != "" {
				fields["comment"] = in.Description
				fields["cmimetype"] = 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 desc map[string]string
			if d.Descriptions != nil {
				desc = d.Descriptions()
			}
			var meta map[string]RepoMeta
			if d.RepoMeta != nil {
				meta = d.RepoMeta()
			}
			// The filter matches names by substring, and descriptions
			// and topics too — a topic pill links here as /?q=<topic>,
			// so a topic must FIND the repositories that carry it.
			matches := func(name string) bool {
				if needle == "" {
					return true
				}
				if strings.Contains(strings.ToLower(name), needle) ||
					strings.Contains(strings.ToLower(desc[name]), needle) {
					return true
				}
				for _, t := range meta[name].Topics {
					if strings.ToLower(t) == needle {
						return true
					}
				}
				// Topics also live in-tree (.quarry/repo.json). The read
				// is the tip-memoized cat repo.about already pays, and it
				// runs only under an active search — the plain listing
				// never touches the stores.
				if repoH, ok := d.Repos.Get(name); ok && repoH.Zit != nil && repoH.Index != nil {
					if _, tip := defaultBranchOf(repoH); tip != "" {
						if raw, cerr := repoH.Zit.Cat(ctx, repoConfigPath, tip); cerr == nil {
							if _, topics, _, perr := parseRepoConfig(raw); perr == nil {
								for _, t := range topics {
									if strings.ToLower(t) == needle {
										return true
									}
								}
							}
						}
					}
				}
				return false
			}
			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 !matches(name) {
					continue
				}
				names = append(names, name)
			}
			var priv map[string]bool
			if d.Private != nil {
				priv = d.Private()
			}
			// One read of the mirror table for the whole listing; the
			// posture per row, and the kind filter, come from it.
			postures := map[string]Mirror{}
			if d.Mirrors != nil {
				if ms, err := d.Mirrors.Mirrors(); err == nil {
					for _, m := range ms {
						postures[m.Repo] = m
					}
				}
			}
			now := time.Now()
			rows := make([]repoInfo, 0, len(names))
			for _, name := range names {
				r, ok := d.Repos.Get(name)
				if !ok {
					continue
				}
				m, tracking := postures[name]
				tracking = tracking && m.Enabled
				if (in.Kind == "mirror" && !tracking) || (in.Kind == "native" && tracking) {
					continue
				}
				info := repoInfo{Name: name, Description: desc[name], Private: priv[name]}
				if tracking {
					info.Mirror = RepoMirror{Tracking: true, URL: m.URL, Every: m.Every,
						LastOK: m.LastOK, LastAt: m.LastAt, Failures: m.Failures,
						AcceptWrites: m.AcceptWrites, State: mirrorState(m, now)}
					info.MirroredAt = m.LastOK
				} else {
					info.DetachedFrom = m.DetachedFrom
				}
				if d.Deletable != nil {
					info.Deletable = d.Deletable(name)
				}
				if r.Indexing != nil {
					info.Indexing = r.Indexing()
				}
				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",
		Caps:  "s",
		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",
		Caps:  "s",
		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",
		Caps:  "a",
		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
			}
			now := time.Now()
			for i := range ms {
				ms[i].State = mirrorState(ms[i], now)
			}
			return mirrorListOut{Mirrors: ms, Count: len(ms)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[mirrorSetIn, mirrorSetOut]{
		Name:  "mirror.set",
		Caps:  "a",
		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
			}
			m.State = mirrorState(m, time.Now())
			return mirrorSetOut{Mirror: m}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[mirrorDetachIn, mirrorDetachOut]{
		Name:  "mirror.detach",
		Caps:  "a",
		Doc:   "Detach a store from its upstream: quarry becomes the source of truth and accepts writes; the upstream is kept as provenance (`detachedFrom`).",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/mirrors/detach",
		Run: func(ctx context.Context, in mirrorDetachIn) (mirrorDetachOut, error) {
			if d.Mirrors == nil {
				return mirrorDetachOut{}, fmt.Errorf("mirroring is not configured: %w", ops.ErrUnavailable)
			}
			if err := d.Mirrors.DetachMirror(in.Repo); err != nil {
				return mirrorDetachOut{}, fmt.Errorf("%w: %v", ops.ErrNotFound, err)
			}
			return mirrorDetachOut{Repo: in.Repo}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[mirrorWritesIn, mirrorWritesOut]{
		Name:  "mirror.writes",
		Caps:  "a",
		Doc:   "Allow or refuse local writes (push, tickets, wiki, forum) on a mirror. Refused by default: a write landing on a tracking store forks it by accident.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/mirrors/writes",
		Run: func(ctx context.Context, in mirrorWritesIn) (mirrorWritesOut, error) {
			if d.Mirrors == nil {
				return mirrorWritesOut{}, fmt.Errorf("mirroring is not configured: %w", ops.ErrUnavailable)
			}
			if err := d.Mirrors.SetMirrorWrites(in.Repo, in.Accept); err != nil {
				return mirrorWritesOut{}, fmt.Errorf("%w: %v", ops.ErrNotFound, err)
			}
			m, _, err := d.Mirrors.MirrorFor(in.Repo)
			if err != nil {
				return mirrorWritesOut{}, err
			}
			m.State = mirrorState(m, time.Now())
			return mirrorWritesOut{Mirror: m}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[mirrorRunIn, mirrorRunOut]{
		Name:  "mirror.run",
		Caps:  "a",
		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",
		Caps:  "s",
		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 {
					// "" is the site-admin marker, not a grant row; the
					// account already says Admin.
					if repo != "" {
						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",
		Caps:  "s",
		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",
		Caps:  "p",
		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",
		Caps:  "s",
		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",
		Caps:  "s",
		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",
		Caps:  "s",
		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",
		Caps:  "o",
		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}
			info.Mirror, info.DetachedFrom = d.posture(in.Repo)
			if info.Mirror.Tracking {
				info.MirroredAt = info.Mirror.LastOK
			}
			if d.Descriptions != nil {
				info.Description = d.Descriptions()[in.Repo]
			}
			if d.Private != nil {
				info.Private = d.Private()[in.Repo]
			}
			if d.RepoMeta != nil {
				if meta, ok := d.RepoMeta()[in.Repo]; ok {
					info.Homepage = meta.Homepage
					info.Topics = strings.Join(meta.Topics, " ")
				}
			}
			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",
		Caps:  "s",
		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
			}
			// Creating a repository is the act of OWNING one: the creator
			// gets an explicit admin grant, which is what the dashboard's
			// "your repositories" means. Site admins could already do
			// everything — the grant records that this one is THEIRS.
			if d.Accounts != nil {
				if who := ops.User(ctx); who != "" {
					if gerr := d.Accounts.Grant(who, in.Name, "admin"); gerr != nil {
						log.Printf("repo.add: owner grant for %s on %s: %v", who, in.Name, gerr)
					}
				}
			}
			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",
		Caps:  "a",
		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 in.Homepage != "" && !strings.HasPrefix(in.Homepage, "http://") &&
				!strings.HasPrefix(in.Homepage, "https://") {
				return repoUpdateOut{}, fmt.Errorf("homepage must be http(s): %w", ops.ErrInvalid)
			}
			topics := strings.Fields(in.Topics)
			if len(topics) > 20 {
				return repoUpdateOut{}, fmt.Errorf("at most 20 topics: %w", ops.ErrInvalid)
			}
			normTopics := strings.Join(topics, " ")
			if err := d.Registry.Update(in.Name, in.Description, visibilityOf(in.Private),
				in.Homepage, normTopics); err != nil {
				return repoUpdateOut{}, err
			}
			return repoUpdateOut{Name: in.Name, Description: in.Description, Private: in.Private,
				Homepage: in.Homepage, Topics: normTopics}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[repoDeleteIn, repoRemoveOut]{
		Name:  "repo.delete",
		Caps:  "s",
		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",
		Caps:  "s",
		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",
		Caps:  "o",
		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
			}
			out := repoCountsOut{
				Checkins: c.Checkins, WikiPages: c.WikiPages, Tickets: c.Tickets,
				Contributors: c.Contributors, Tags: c.Tags,
				ForumThreads: c.ForumThreads, Technotes: c.Technotes,
			}
			// THE staleness signal, computed once where every page
			// already looks: the store's authoritative tip (zit's) is
			// not in the index, so anything history-derived on the page
			// may be incomplete. The invariant is honesty, not
			// freshness — a page may lag, it may never lie about it.
			if _, ztip := defaultBranchOf(repo); ztip != "" {
				if _, _, ok, ierr := repo.Index.CheckinByPrefix(ztip); ierr == nil && !ok {
					out.IndexBehind = true
				}
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[timelineIn, timelineOut]{
		Name:  "timeline.list",
		Caps:  "o",
		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
			}
			behind := false
			if _, ztip := defaultBranchOf(repo); ztip != "" {
				if _, _, ok, ierr := repo.Index.CheckinByPrefix(ztip); ierr == nil && !ok {
					behind = true
				}
			}
			// 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), IndexBehind: behind}
			if name, _ := defaultBranchOf(repo); name != "" {
				out.DefaultBranch = name
			}
			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",
		Caps:  "j",
		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
			}
			out := wikiListOut{Pages: pages, Count: len(pages)}
			for _, p := range pages {
				if strings.EqualFold(p.Title, "home") {
					out.Home = p.Title
					break
				}
			}
			// Under a handful of pages neither question has an answer
			// worth reading, and asking costs a scan of every body.
			if len(pages) >= wikiStructureFloor {
				if st, serr := repo.Index.WikiLinks(out.Home, markup.WikiRefs); serr == nil {
					out.Orphans, out.Wanted = st.Orphans, st.Wanted
				}
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[reindexIn, reindexOut]{
		Name:  "repo.reindex",
		Caps:  "a",
		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",
		Caps:  "p",
		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, in meFeedIn) (meFeedOut, error) {
			// 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
			user, names, err := d.myRepos(ctx, repoCap)
			if err != nil {
				return meFeedOut{}, err
			}
			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
				}
				if open, _, cerr := repo.Index.TicketCounts(index.TicketFilter{}); cerr == nil {
					dr.Open = open
				}
				if in.Find == "" || strings.Contains(strings.ToLower(name), strings.ToLower(in.Find)) {
					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 })
			// Counted over the window the chips filter, so a chip's
			// number is exactly what clicking it shows.
			byKind := map[string]int{}
			for _, e := range all {
				byKind[e.Kind]++
			}
			for _, k := range []string{"ci", "t", "w", "f", "e"} {
				if n := byKind[k]; n > 0 {
					out.Kinds = append(out.Kinds, kindCount{Kind: k, Count: n})
				}
			}
			for _, e := range all {
				// Mine is the caller's own record and answers its own
				// question; a chip on the feed must not quietly rewrite
				// what a different field means.
				if e.User == user && len(out.Mine) < mineCap {
					out.Mine = append(out.Mine, e)
				}
				if in.Kind != "" && e.Kind != in.Kind {
					continue
				}
				if len(out.Feed) < feedCap {
					out.Feed = append(out.Feed, e)
				}
			}
			return out, nil
		},
	}); err != nil {
		return err
	}

	if err := ops.Register(r, ops.Op[aboutIn, aboutOut]{
		Name:  "repo.about",
		Caps:  "o",
		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 := defaultBranchOf(repo); tip != "" {
					version = tip
				}
				if tags, terr := repo.Index.Tags(1); terr == nil && len(tags) > 0 {
					out.LatestTag, out.LatestTagDate = tags[0].Name, tags[0].Date
					out.LatestTagTip = tags[0].Tip
					if nn, nerr := repo.Index.TagCount(); nerr == nil {
						out.TagCount = nn
					}
				}
				if c, cerr := repo.Index.Counts(); cerr == nil {
					out.Contributors = c.Contributors
				}
				if d.Forum != nil {
					if cats, cerr := d.Forum.ThreadCategories(in.Repo); cerr == nil && len(cats) > 0 {
						if threads, terr := repo.Index.ForumThreads(); terr == nil {
							for _, t := range threads { // newest-active first
								if cats[t.Root] == announcements {
									out.AnnounceRoot, out.AnnounceTitle, out.AnnounceDate = t.Root, t.Title, t.Updated
									break
								}
							}
						}
					}
				}
			}
			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
					}
				}
			}
			// Settings are the maintainer's live edit and win over the
			// in-tree file when set — GitHub's model, with the file as
			// the versioned alternative for projects that prefer it.
			if d.RepoMeta != nil {
				if meta, ok := d.RepoMeta()[in.Repo]; ok {
					if meta.Homepage != "" {
						out.Homepage = meta.Homepage
					}
					if len(meta.Topics) > 0 {
						out.Topics = meta.Topics
					}
				}
			}
			if len(out.Docs) == 0 && out.Problem == "" {
				out.Docs = markerDocs(files)
			}
			if repo.Zit != nil {
				for _, dc := range out.Docs {
					if !strings.HasPrefix(strings.ToLower(path.Base(dc.Path)), "license") &&
						!strings.HasPrefix(strings.ToLower(path.Base(dc.Path)), "copying") {
						continue
					}
					if body, berr := repo.Zit.Cat(ctx, dc.Path, version); berr == nil {
						out.LicenseName = detectLicense(string(body))
					}
					break
				}
			}
			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",
		Caps:  "r",
		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 := defaultBranchOf(repo); tip != "" {
					version = 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",
		Caps:  "r",
		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
			}
			filter := index.TicketFilter{
				State: in.State, Type: in.Type, Severity: in.Severity,
				Priority: in.Priority, Subsystem: in.Subsystem,
				Status: in.Status, Author: in.Author,
				Title: in.Title, Order: in.Order, Offset: in.Offset,
			}
			all, more, err := repo.Index.TicketSummaries(in.Limit, in.Before, filter)
			if err != nil {
				return ticketListOut{}, err
			}
			out := ticketListOut{Count: len(all)}
			// Counted through the same filter, so the header describes
			// the listing rather than the tracker behind it.
			if out.Open, out.Closed, err = repo.Index.TicketCounts(filter); err != nil {
				return ticketListOut{}, err
			}
			if out.Subsystems, out.Authors, err = repo.Index.TicketFacets(); err != nil {
				return ticketListOut{}, err
			}
			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,
					Author: t.Author, LastUser: t.LastUser, Comments: t.Comments,
				})
			}
			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[ticketAcrossIn, ticketAcrossOut]{
		Name:  "ticket.across",
		Caps:  "p",
		Doc:   "Every tracker the caller can reach, as one listing: the same report axes as ticket.list asked of all granted repositories at once, with repository as one more browse axis.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/me/tickets",
		Run: func(ctx context.Context, in ticketAcrossIn) (ticketAcrossOut, error) {
			const repoCap = 25
			user, names, err := d.myRepos(ctx, repoCap)
			if err != nil {
				return ticketAcrossOut{}, err
			}
			if in.Repo != "" {
				// Narrowing to a tracker you cannot reach returns the
				// empty listing, never someone else's tickets.
				kept := names[:0:0]
				for _, n := range names {
					if n == in.Repo {
						kept = append(kept, n)
					}
				}
				names = kept
			}
			filter := index.TicketFilter{
				State: in.State, Type: in.Type, Severity: in.Severity,
				Priority: in.Priority, Subsystem: in.Subsystem,
				Status: in.Status, Author: in.Author,
				Title: in.Title, Order: in.Order,
			}
			if in.Awaiting {
				filter.Author, filter.State = user, "open"
			}
			out := ticketAcrossOut{}
			subs, authors := map[string]int{}, map[string]int{}
			// Each tracker contributes at most one page, so a single
			// busy repo cannot crowd out the rest of the listing.
			perRepo := in.Limit
			if perRepo <= 0 || perRepo > 200 {
				perRepo = 50
			}
			for _, name := range names {
				repo, ok := d.Repos.Get(name)
				if !ok || repo.Index == nil {
					continue
				}
				rows, more, rerr := repo.Index.TicketSummaries(perRepo, "", filter)
				if rerr != nil {
					continue // one broken index must not empty the page
				}
				out.Truncated = out.Truncated || more
				if !in.Awaiting {
					open, closed, cerr := repo.Index.TicketCounts(filter)
					if cerr == nil {
						out.Open, out.Closed = out.Open+open, out.Closed+closed
					}
				}
				// A band of six needs no browse axes, and building them
				// is three queries per tracker that nothing reads.
				if !in.Awaiting {
					if o, _, ferr := repo.Index.TicketCounts(index.TicketFilter{}); ferr == nil && o > 0 {
						out.Repos = append(out.Repos, index.TicketFacet{Value: name, Open: o})
					}
					if rs, as, ferr := repo.Index.TicketFacets(); ferr == nil {
						for _, f := range rs {
							subs[f.Value] += f.Open
						}
						for _, f := range as {
							authors[f.Value] += f.Open
						}
					}
				}
				for _, t := range rows {
					// Nobody is waiting on themselves, and a ticket
					// nobody has answered yet is not yet a reply owed.
					if in.Awaiting && (t.LastUser == "" || t.LastUser == user) {
						continue
					}
					out.Tickets = append(out.Tickets, acrossTicket{Repo: name, ticketSummary: 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,
						Author: t.Author, LastUser: t.LastUser, Comments: t.Comments,
					}})
				}
			}
			// Newest-updated first, the same order one tracker lists in.
			sort.SliceStable(out.Tickets, func(i, j int) bool {
				return out.Tickets[i].Updated > out.Tickets[j].Updated
			})
			limit := in.Limit
			if limit <= 0 {
				limit = 50
			}
			if len(out.Tickets) > limit {
				out.Tickets, out.Truncated = out.Tickets[:limit], true
			}
			out.Count = len(out.Tickets)
			if in.Awaiting {
				// The rule is not one SQL count can express, so the
				// header describes exactly the rows below it.
				out.Open, out.Closed = out.Count, 0
			}
			out.Subsystems, out.Authors = mergedFacets(subs), mergedFacets(authors)
			sort.SliceStable(out.Repos, func(i, j int) bool {
				if out.Repos[i].Open != out.Repos[j].Open {
					return out.Repos[i].Open > out.Repos[j].Open
				}
				return out.Repos[i].Value < out.Repos[j].Value
			})
			return out, nil
		},
	}); err != nil {
		return err
	}
	if d.Views != nil {
		if err := ops.Register(r, ops.Op[viewListIn, viewListOut]{
			Name:  "view.list",
			Caps:  "p",
			Doc:   "The caller's saved reports for one listing: a repository's tracker, or the cross-repository listing when repo is empty.",
			Kind:  ops.Query,
			Auth:  ops.Token,
			Route: "GET /api/v1/me/views",
			Run: func(ctx context.Context, in viewListIn) (viewListOut, error) {
				user := ops.User(ctx)
				if user == "" {
					return viewListOut{}, fmt.Errorf("no signed-in account: %w", ops.ErrNotFound)
				}
				views, err := d.Views.SavedViews(user, in.Repo)
				if err != nil {
					return viewListOut{}, err
				}
				return viewListOut{Views: views}, nil
			},
		}); err != nil {
			return err
		}
		if err := ops.Register(r, ops.Op[viewSaveIn, viewSaveOut]{
			Name:  "view.save",
			Caps:  "p",
			Doc:   "Name a report so it appears in the caller's rail. The query is a URL query string this forge could already produce; saving twice under one name corrects it.",
			Kind:  ops.Command,
			Auth:  ops.Token,
			Route: "POST /api/v1/me/views",
			Run: func(ctx context.Context, in viewSaveIn) (viewSaveOut, error) {
				user := ops.User(ctx)
				if user == "" {
					return viewSaveOut{}, fmt.Errorf("no signed-in account: %w", ops.ErrNotFound)
				}
				name := strings.TrimSpace(in.Name)
				if name == "" {
					return viewSaveOut{}, fmt.Errorf("a view needs a name: %w", ops.ErrInvalid)
				}
				// The query is stored verbatim but must PARSE as one:
				// a rail entry is a link, and a link built from
				// something that is not a query string is a broken
				// control that looks like a working one.
				q, perr := url.ParseQuery(strings.TrimPrefix(in.Query, "?"))
				if perr != nil {
					return viewSaveOut{}, fmt.Errorf("not a report: %w", ops.ErrInvalid)
				}
				if err := d.Views.SaveView(user, in.Repo, name, q.Encode()); err != nil {
					return viewSaveOut{}, err
				}
				return viewSaveOut{Name: name, Query: q.Encode()}, nil
			},
		}); err != nil {
			return err
		}
		if err := ops.Register(r, ops.Op[viewDeleteIn, viewDeleteOut]{
			Name:  "view.delete",
			Caps:  "p",
			Doc:   "Drop one of the caller's saved reports. The listing it named is unaffected: only the shortcut goes.",
			Kind:  ops.Command,
			Auth:  ops.Token,
			Route: "POST /api/v1/me/views/delete",
			Run: func(ctx context.Context, in viewDeleteIn) (viewDeleteOut, error) {
				user := ops.User(ctx)
				if user == "" {
					return viewDeleteOut{}, fmt.Errorf("no signed-in account: %w", ops.ErrNotFound)
				}
				if err := d.Views.DeleteView(user, in.Repo, in.Name); err != nil {
					return viewDeleteOut{}, err
				}
				return viewDeleteOut{Deleted: in.Name}, nil
			},
		}); err != nil {
			return err
		}
	}
	if err := ops.Register(r, ops.Op[ticketGetIn, ticketGetOut]{
		Name:  "ticket.get",
		Caps:  "r",
		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",
		Caps:  "jr",
		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",
		Caps:  "o",
		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",
		Caps:  "z",
		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",
		Caps:  "o",
		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",
		Caps:  "o",
		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",
		Caps:  "o",
		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
			}
			ref := in.Version
			var version string
			if ref == "" {
				// A nameless request means the repository's DEFAULT
				// branch — which is not always literally "trunk": a
				// repository that renamed its trunk (tk, tcl) keeps
				// the old name in the branch table forever. The tip
				// comes from zit with the name: pinning the name via
				// the index serves a stale tree whenever the index
				// lags the store.
				var tip string
				ref, tip = defaultBranchOf(repo)
				version = tip
			}
			if version == "" {
				version = d.resolveVersion(repo, ref)
				// Resolve a moving name to the check-in it currently
				// means: the listing is then cacheable (a hash cannot
				// move, a name can), and the page's links carry the
				// exact check-in a reader was looking at.
				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
			})
			// The last change per entry: an exact-path indexed lookup per
			// row (directories have their own fold rows). Restricted to
			// the viewed branch when the version names one, GitHub's
			// semantics; a detached hash view goes unfiltered, which can
			// name a change newer than the viewed check-in — the honest
			// cost of not walking ancestry per row.
			if repo.Index != nil {
				branch := ""
				if _, ok, berr := repo.Index.TipOf(in.Version); berr == nil && ok {
					branch = in.Version
				}
				for i := range entries {
					e, ok, lerr := repo.Index.LastChange(entries[i].Path, branch)
					if lerr != nil || !ok {
						continue
					}
					entries[i].LastHash, entries[i].LastDate = e.Hash, e.Date
					entries[i].LastComment = e.Comment
				}
			}
			out := treeOut{Version: version, Ref: ref, 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",
		Caps:  "h",
		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",
		Caps:  "o",
		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",
		Caps:  "o",
		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[resolveIn, resolveOut]{
		Name:  "artifact.resolve",
		Caps:  "o",
		Doc:   "Resolve an artifact hash prefix to its kind and canonical reference — fossil's /info dispatch.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/info/{prefix}",
		Run: func(ctx context.Context, in resolveIn) (resolveOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return resolveOut{}, err
			}
			prefix := strings.ToLower(in.Prefix)
			for _, c := range prefix {
				if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
					return resolveOut{}, fmt.Errorf("prefix %q is not hex: %w", in.Prefix, ops.ErrInvalid)
				}
			}
			if e, _, ok, err := repo.Index.CheckinByPrefix(prefix); err != nil {
				return resolveOut{}, err
			} else if ok {
				return resolveOut{Kind: "ci", Ref: e.Hash}, nil
			}
			// The reader-facing lookup: a private ticket resolves to
			// nothing, exactly as it would render as plain text.
			if t, ok, err := repo.Index.TicketByPrefix(prefix); err != nil {
				return resolveOut{}, err
			} else if ok {
				return resolveOut{Kind: "t", Ref: t.ID}, nil
			}
			if kind, ref, ok, err := repo.Index.EventByPrefix(prefix); err != nil {
				return resolveOut{}, err
			} else if ok {
				return resolveOut{Kind: kind, Ref: ref}, nil
			}
			return resolveOut{}, fmt.Errorf("no unique artifact for %q: %w", in.Prefix, ops.ErrNotFound)
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[diffGetIn, diffGetOut]{
		Name:  "checkin.diff",
		Caps:  "o",
		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)
			if in.File != "" {
				var one []diff.FileDiff
				for _, f := range out.Structured {
					if f.Path == in.File || f.PrevPath == in.File {
						one = append(one, f)
					}
				}
				out.Structured = one
			}
			// Cut before enriching: highlighting and word-marking are
			// per line, and paying them for lines nobody sees is what
			// made a large merge a twenty-second page.
			out.LastOffset = diff.LastWindow(out.Structured, in.MaxLines)
			start, kept, after := diff.Window(out.Structured, in.Offset, in.MaxLines)
			out.Offset, out.OmittedLines, out.NextOffset = start, after, start+kept
			out.TotalLines = start + kept + after
			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[docListIn, docListOut]{
		Name:  "doc.list",
		Caps:  "o",
		Doc:   "List the documents in a check-in's conventional documentation directories.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/doc",
		Run: func(ctx context.Context, in docListIn) (docListOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return docListOut{}, err
			}
			if repo.Zit == nil {
				// No working store: the repository has an index and
				// nothing to list a tree from. Not an error — it simply
				// has no tree documents, only whatever the wiki holds.
				return docListOut{}, nil
			}
			version := d.resolveCheckin(ctx, repo, in.Version)
			files, err := repo.Zit.Ls(ctx, version)
			if err != nil {
				return docListOut{}, err
			}
			out := docListOut{Version: version}
			var paths []string
			for _, f := range files {
				if f.Symlink() || !isDocPath(f.Name) {
					continue
				}
				out.Docs = append(out.Docs, docEntry{Path: f.Name, Size: f.Size})
				paths = append(paths, f.Name)
			}
			if repo.Index != nil && len(paths) > 0 {
				// Dated in one scan; a listing that cannot say what is
				// current is the problem this index exists to fix.
				if changes, derr := repo.Index.LastChanges(paths); derr == nil {
					for i := range out.Docs {
						c := changes[out.Docs[i].Path]
						out.Docs[i].Date, out.Docs[i].User = c.Date, c.User
					}
				}
			}
			sort.Slice(out.Docs, func(i, j int) bool {
				return out.Docs[i].Path < out.Docs[j].Path
			})
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[docGetIn, docGetOut]{
		Name: "doc.get",
		Caps: "o",
		Doc:  "Render one file from the tree as embedded documentation, chosen by extension.",
		Kind: ops.Query,
		Auth: ops.Anonymous,
		// The URL mirrors the tree path, which is not decoration: a
		// relative link inside a document then resolves to the sibling
		// document by ordinary browser rules, with nothing to rewrite.
		Route: "GET /api/v1/r/{repo}/doc/{hash}/{path...}",
		Run: func(ctx context.Context, in docGetIn) (docGetOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return docGetOut{}, err
			}
			version := d.resolveCheckin(ctx, repo, in.Hash)
			path := in.Path
			ctype, direct := docDirect(path)
			mimetype, ok := docMimetype(path)
			if !ok && !direct {
				// A directory serves its index, the way every web
				// server has since 1993 — and the way fossil's own docs
				// assume when they link [./server/].
				if idx, found := docIndexOf(ctx, repo, version, path); found {
					path = idx
					mimetype, ok = docMimetype(path)
				}
			}
			if !ok && !direct {
				return docGetOut{}, fmt.Errorf("%q is not a document quarry renders: %w",
					in.Path, ops.ErrNotFound)
			}
			raw, err := repo.Zit.Cat(ctx, path, version)
			if errors.Is(err, zitcli.ErrNoEntry) {
				return docGetOut{}, fmt.Errorf("no file %q at this check-in: %w",
					path, ops.ErrNotFound)
			}
			if err != nil {
				return docGetOut{}, err
			}
			if direct {
				// An image inside a document: the markup already points
				// at this URL relative to itself, so it only has to
				// answer with the bytes.
				if len(raw) > maxAttachmentBytes {
					return docGetOut{}, fmt.Errorf("%q is %d bytes, over the %d limit: %w",
						in.Path, len(raw), maxAttachmentBytes, ops.ErrInvalid)
				}
				return docGetOut{Hash: version, Path: path, Size: len(raw),
					ContentType: ctype,
					Content:     base64.StdEncoding.EncodeToString(raw)}, nil
			}
			html, err := renderBodyWith(docResolver{resolver{ix: repo.Index, repo: in.Repo}},
				mimetype, raw)
			if err != nil {
				return docGetOut{}, err
			}
			anchored, outline := markup.Outline([]byte(html))
			return docGetOut{
				Hash: version, Path: path, Size: len(raw),
				Mimetype: mimetype, HTML: string(anchored), Outline: outline,
			}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[fileGetIn, fileGetOut]{
		Name:  "file.get",
		Caps:  "o",
		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.resolveCheckin(ctx, 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
			}
			_, renderable := docMimetype(in.Path)
			out := fileGetOut{Hash: version, Path: in.Path, Size: len(raw),
				Renderable: renderable}
			// 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",
		Caps:  "o",
		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,
			}
			if out.DiscussedIn, err = repo.Index.ThreadsMentioning(e.Hash); err != nil {
				return checkinGetOut{}, err
			}
			// 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 && in.Files != "0" {
				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",
		Caps:  "j",
		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",
		Caps:  "j",
		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
			}
			// Anchored here, not in the web layer, so a script reading
			// the API gets the same addressable document a reader does.
			anchored, outline := markup.Outline([]byte(html))
			return wikiGetOut{
				Hash: page.Hash, Title: page.Title, Date: page.Date, User: page.User,
				Mimetype: page.Mimetype, Source: string(page.Body),
				HTML: string(anchored), Outline: outline,
				// 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",
		Caps:  "j",
		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",
		Caps:  "j",
		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",
		Caps:  "2",
		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
			}
			out := forumListOut{Count: len(threads)}
			var scores map[string]int
			if d.Votes != nil && len(threads) > 0 {
				roots := make([]string, len(threads))
				for i, t := range threads {
					roots[i] = t.Root
				}
				if scores, err = d.Votes.ForumThreadScores(in.Repo, roots); err != nil {
					return forumListOut{}, err
				}
			}
			var cats map[string]string
			if d.Forum != nil {
				if cats, err = d.Forum.ThreadCategories(in.Repo); err != nil {
					return forumListOut{}, err
				}
			}
			now := time.Now().UTC()
			for _, t := range threads {
				row := forumThreadOut{ForumThread: t, Score: scores[t.Root], Category: cats[t.Root]}
				row.Spark = sparkBins(t.Dates, 12)
				row.Dates = nil // the sparkline is the shape; the raw series stays home
				if u := t.Updated; len(u) >= 19 {
					if ut, err := time.Parse("2006-01-02T15:04:05", u[:19]); err == nil {
						row.Dormant = now.Sub(ut) > hotWindow
					}
				}
				if row.Category == announcements {
					out.Announcements = append(out.Announcements, row)
				} else {
					out.Threads = append(out.Threads, row)
				}
			}
			if in.Sort == "hot" {
				sortForumHot(out.Threads, time.Now().UTC())
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[forumGetIn, forumGetOut]{
		Name:  "forum.get",
		Caps:  "2",
		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}
			if len(posts) > 0 {
				out.Excerpt = index.ThreadExcerpt(posts[0].Body)
			}
			var hidden map[string]string
			if d.Forum != nil {
				if hidden, err = d.Forum.HiddenPosts(in.Repo); err != nil {
					return forumGetOut{}, err
				}
				if len(posts) > 0 {
					out.YourSub = d.Forum.ForumSubState(in.Repo, ops.User(ctx), posts[0].Hash)
				}
			}
			var scores, mine map[string]int
			if d.Votes != nil {
				hashes := make([]string, len(posts))
				for i, p := range posts {
					hashes[i] = p.Hash
				}
				if scores, err = d.Votes.ForumPostScores(in.Repo, hashes); err != nil {
					return forumGetOut{}, err
				}
				if mine, err = d.Votes.ForumAccountVotes(in.Repo, ops.User(ctx), hashes); err != nil {
					return forumGetOut{}, err
				}
			}
			for _, p := range posts {
				body := p.Body
				var poll pollRendered
				if def, rest := parsePoll(body); def != nil && d.Forum != nil {
					body = rest
					counts, cerr := d.Forum.PollCounts(in.Repo, p.Hash)
					if cerr != nil {
						return forumGetOut{}, cerr
					}
					poll = pollRendered{Present: true, Question: def.Question, Options: def.Options,
						Counts: make([]int, len(def.Options)), Percents: make([]int, len(def.Options)),
						YourChoice: -1, Closed: def.closed(time.Now()), Closes: def.Closes}
					for i := range def.Options {
						poll.Counts[i] = counts[i]
						poll.Total += counts[i]
					}
					for i, n := range poll.Counts {
						if poll.Total > 0 {
							poll.Percents[i] = n * 100 / poll.Total
						}
					}
					if c, ok := d.Forum.PollChoice(in.Repo, ops.User(ctx), p.Hash); ok {
						poll.YourChoice = c
					}
				}
				// 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(body))
				if err != nil {
					return forumGetOut{}, err
				}
				reason, isHidden := hidden[p.Hash]
				out.Posts = append(out.Posts, forumRendered{
					Hash: p.Hash, Parent: p.Parent, Date: p.Date, User: p.User, HTML: html,
					Score: scores[p.Hash], YourVote: mine[p.Hash], Poll: poll,
					Hidden: isHidden, HiddenReason: reason,
				})
			}
			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[markupPreviewIn, markupPreviewOut]{
		Name: "markup.preview",
		Doc:  "Render markdown exactly as saving it would — the composer's preview IS the production renderer, so what you see is what readers get.",
		Kind: ops.Command,
		Auth: ops.Token,
		// Same sanitizer, same resolver: hash refs, [[wiki]] links and
		// the whole pipeline behave identically to a saved post.
		Route: "POST /api/v1/r/{repo}/markup-preview",
		Run: func(ctx context.Context, in markupPreviewIn) (markupPreviewOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return markupPreviewOut{}, err
			}
			// A ```poll block previews as the WIDGET it will become,
			// not as a code fence — same substitution forum.get makes.
			body, widget := in.Body, ""
			if def, rest := parsePoll(body); def != nil {
				body, widget = rest, pollPreviewHTML(def)
			}
			html, err := renderBody(repo.Index, in.Repo, authoredMimetype, []byte(body))
			if err != nil {
				return markupPreviewOut{}, err
			}
			return markupPreviewOut{HTML: html + widget}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[forumPostIn, forumPostOut]{
		Name:  "forum.post",
		Caps:  "3",
		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) {
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return forumPostOut{}, err
			}
			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
			}
			if held, herr := maybeStage(ctx, d, in.Repo, "post", "", in.Title, in.Body, mt); herr != nil {
				return forumPostOut{}, herr
			} else if held {
				return forumPostOut{Staged: true}, nil
			}
			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,
			})
			forumFanout(d, in.Repo, hash, hash, author(ctx), in.Title, in.Body, "")
			return forumPostOut{Hash: hash}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[forumReplyIn, forumReplyOut]{
		Name:  "forum.reply",
		Caps:  "3",
		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) {
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return forumReplyOut{}, err
			}
			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, root, parentAuthor := in.Root, in.Root, ""
			if _, posts, ok, perr := repo.Index.ForumThreadPosts(in.Root); perr == nil && ok && len(posts) > 0 {
				root = posts[0].Hash
				post = root // default: answer the thread itself
				if in.To != "" {
					for _, p := range posts {
						if strings.HasPrefix(p.Hash, in.To) {
							post = p.Hash
							// Personal only when aimed BELOW the root:
							// answering the thread is thread traffic and
							// respects unsubscribes; answering a person
							// reaches them regardless.
							if p.Hash != root {
								parentAuthor = p.User
							}
							break
						}
					}
				}
			}
			if held, herr := maybeStage(ctx, d, in.Repo, "reply", post, "", in.Body, in.Mimetype); herr != nil {
				return forumReplyOut{}, herr
			} else if held {
				return forumReplyOut{Staged: true}, nil
			}
			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,
			})
			forumFanout(d, in.Repo, root, hash, author(ctx), "", in.Body, parentAuthor)
			return forumReplyOut{Hash: hash}, nil
		},
	}); err != nil {
		return err
	}
	if d.Forum != nil {
		if err := ops.Register(r, ops.Op[forumPostIn, forumPostOut]{
			Name: "forum.announce",
			Caps: "5",
			Doc:  "Open an announcement thread — the maintainer-only pinned category.",
			Kind: ops.Command,
			// Admin is the category's who-may-post policy, enforced by
			// the op's own auth level: announcement-as-a-type, not a
			// flag an ordinary post could set. Replies stay open to
			// everyone through forum.reply.
			Auth:  ops.Admin,
			Route: "POST /api/v1/r/{repo}/forum-announce",
			Run: func(ctx context.Context, in forumPostIn) (forumPostOut, error) {
				repo, err := d.repoFor(ctx, in.Repo)
				if err != nil {
					return forumPostOut{}, err
				}
				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 := d.Forum.SetThreadCategory(in.Repo, hash, announcements); 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": "announced " + in.Title,
						"link":    "/r/" + in.Repo + "/forum/" + hash[:min(16, len(hash))]},
					TxnID: "activity:forum-announce:" + hash,
				})
				forumFanout(d, in.Repo, hash, hash, author(ctx), in.Title, in.Body, "")
				return forumPostOut{Hash: hash}, nil
			},
		}); err != nil {
			return err
		}
		if err := ops.Register(r, ops.Op[pollVoteIn, pollVoteOut]{
			Name:  "forum.poll",
			Caps:  "3",
			Doc:   "Cast (or change) a ballot in a post's poll; ballots freeze when the poll closes.",
			Kind:  ops.Command,
			Auth:  ops.Token,
			Route: "POST /api/v1/r/{repo}/forum/{post}/poll",
			Run: func(ctx context.Context, in pollVoteIn) (pollVoteOut, error) {
				repo, err := d.repoFor(ctx, in.Repo)
				if err != nil {
					return pollVoteOut{}, err
				}
				account := ops.User(ctx)
				if account == "" {
					return pollVoteOut{}, fmt.Errorf("voting needs a signed-in account: %w", ops.ErrInvalid)
				}
				post, thread, ok, err := repo.Index.ForumPostThread(in.Post)
				if err != nil {
					return pollVoteOut{}, err
				}
				if !ok {
					return pollVoteOut{}, fmt.Errorf("no forum post %q: %w", in.Post, ops.ErrNotFound)
				}
				// The ballot's legitimacy comes from the post body — the
				// synced definition — re-read at cast time: option range
				// and close date are never trusted from the form.
				_, posts, _, err := repo.Index.ForumThreadPosts(thread)
				if err != nil {
					return pollVoteOut{}, err
				}
				var def *pollDef
				for _, p := range posts {
					if p.Hash == post {
						def, _ = parsePoll(p.Body)
					}
				}
				if def == nil {
					return pollVoteOut{}, fmt.Errorf("post %q carries no poll: %w", in.Post, ops.ErrInvalid)
				}
				if def.closed(time.Now()) {
					return pollVoteOut{}, fmt.Errorf("poll closed %s: %w", def.Closes, ops.ErrInvalid)
				}
				if in.Choice < 0 || in.Choice >= len(def.Options) {
					return pollVoteOut{}, fmt.Errorf("choice %d of %d options: %w", in.Choice, len(def.Options), ops.ErrInvalid)
				}
				if err := d.Forum.SetPollVote(in.Repo, account, post, in.Choice); err != nil {
					return pollVoteOut{}, err
				}
				return pollVoteOut{Post: post, Choice: in.Choice}, nil
			},
		}); err != nil {
			return err
		}
		if err := registerModeration(r, d); err != nil {
			return err
		}
		if err := registerSubs(r, d); err != nil {
			return err
		}
	}
	if d.Votes != nil {
		if err := ops.Register(r, ops.Op[forumVoteIn, forumVoteOut]{
			Name:  "forum.vote",
			Caps:  "3",
			Doc:   "Vote on a forum post: up, down, or none to retract.",
			Kind:  ops.Command,
			Auth:  ops.Token,
			Route: "POST /api/v1/r/{repo}/forum/{post}/vote",
			Run: func(ctx context.Context, in forumVoteIn) (forumVoteOut, error) {
				repo, err := d.repoFor(ctx, in.Repo)
				if err != nil {
					return forumVoteOut{}, err
				}
				// A vote needs a real identity: the (account, post) key is
				// the one-vote-per-voter rule, and "anonymous" would pool
				// every stranger into a single shared vote.
				account := ops.User(ctx)
				if account == "" {
					return forumVoteOut{}, fmt.Errorf("voting needs a signed-in account: %w", ops.ErrInvalid)
				}
				post, thread, ok, err := repo.Index.ForumPostThread(in.Post)
				if err != nil {
					return forumVoteOut{}, err
				}
				if !ok {
					return forumVoteOut{}, fmt.Errorf("no forum post %q: %w", in.Post, ops.ErrNotFound)
				}
				dir := map[string]int{"up": 1, "down": -1, "none": 0}[in.Dir]
				if err := d.Votes.SetForumVote(in.Repo, account, post, thread, dir); err != nil {
					return forumVoteOut{}, err
				}
				scores, err := d.Votes.ForumPostScores(in.Repo, []string{post})
				if err != nil {
					return forumVoteOut{}, err
				}
				return forumVoteOut{Post: post, Score: scores[post]}, nil
			},
		}); err != nil {
			return err
		}
	}
	return ops.Register(r, ops.Op[technoteAddIn, technoteAddOut]{
		Name:  "technote.add",
		Caps:  "k",
		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) {
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return technoteAddOut{}, err
			}
			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",
		Caps:  "C",
		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).
	return renderBodyWith(resolver{ix: ix, repo: repo}, mimetype, body)
}

// renderBodyWith is renderBody with the reference resolver chosen by the
// caller. A document from the tree resolves relative links; a wiki
// artifact, which sits at no path, must not.
func renderBodyWith(res markup.Resolver, mimetype string, body []byte) (string, error) {
	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/html", "text/x-html":
		// Repository-authored HTML, rendered rather than printed. Fossil
		// serves this close to as-is and warns that a <script> inside it
		// trips the browser's CSP; quarry sends it through the same
		// sanitizer every other rendered path uses, because "the
		// sanitizer is the gate" is the rule that makes serving someone
		// else's repository safe. The visible cost is that a doc leaning
		// on inline style or script renders plainer here than on fossil.
		return markup.Sanitize(string(body)), 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:
		// A whole document rendered as plain text is a page, not a code
		// snippet: the class lets it sit on the page background instead
		// of in a code box.
		return `<pre class="plaindoc">` + 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
}