Skip to content
quarry Artifact [54f70f1034]
Login

Artifact [54f70f1034]

Artifact 54f70f1034ed847bb2fffea908e1276f3e9e9f11562163a22e6f8b6b04b80656:


// 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"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"html"
	"io"
	"log"
	"math"
	"net/url"
	"os"
	"path"
	pathpkg "path"
	"regexp"
	"slices"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"
	"unicode/utf8"

	"craftdesign.group/quarry/internal/archive"
	"craftdesign.group/quarry/internal/attach"
	"craftdesign.group/quarry/internal/chat"
	"craftdesign.group/quarry/internal/diff"
	"craftdesign.group/quarry/internal/errlog"
	"craftdesign.group/quarry/internal/evt"
	"craftdesign.group/quarry/internal/highlight"
	"craftdesign.group/quarry/internal/idn"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/ingest"
	"craftdesign.group/quarry/internal/markup"
	"craftdesign.group/quarry/internal/obs"
	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
	"craftdesign.group/quarry/internal/sniff"
	"craftdesign.group/quarry/internal/webhook"
	"craftdesign.group/quarry/internal/work"
	"craftdesign.group/quarry/internal/xfer"
	"craftdesign.group/quarry/internal/zitcli"

	"craftdesign.group/quarry/internal/fault"
)

// 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, RFC 0011 §2). A nil
// Deps.Chat disables chat (no event plane).
type ChatPlane interface {
	// Post publishes a message and returns the stream sequence the
	// broker assigned it — the message's permanent identity.
	Post(code string, m chat.Message) (seq uint64, err error)
	// History pages backward from before (0 = latest), oldest-first,
	// plus next: the seq to pass as before for the older page, 0 when
	// there is none.
	History(code string, before uint64, limit int) (msgs []chat.Message, next uint64, err error)
	// Forward is History's mirror: at most limit messages with seq
	// after `after`, oldest-first, plus next — the seq to continue
	// from, 0 when the page reaches the channel's newest message.
	Forward(code string, after uint64, limit int) (msgs []chat.Message, next uint64, err error)
	// Purge erases sequences from the stream. Irreversible, and the
	// implementation must refuse a sequence belonging to another
	// repository's channel.
	Purge(code string, seqs ...uint64) error
	// Tail delivers messages from seq from+1 onward (0 = new only)
	// until cancel, never dropping under backpressure.
	Tail(code string, from uint64) (<-chan chat.Message, func())
	// Range returns messages with seq >= from (seq <= to too, when
	// to > 0) — the bounded forward scan behind chat.get and
	// chat.edit/delete.
	Range(code string, from, to uint64) ([]chat.Message, error)
	// Bounds answers the channel's first and last sequence, 0,0 when
	// nothing has ever been posted.
	Bounds(code string) (first, last uint64, err error)
	// Unread counts THIS channel's messages after since. It exists
	// because last-since is not that number: sequences belong to the
	// stream every repository shares, so the subtraction counts the
	// neighbours' traffic too.
	Unread(code string, since uint64) (int, 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 {
	// Kind names the event in RFC 0005's vocabulary. Stated at the site
	// where the thing happened, because only that site knows: the old
	// code sliced a noun out of the TxnID, which made the event's TYPE a
	// property of how somebody spelled an idempotency key.
	Kind     evt.Kind
	Workflow string         // notify workflow name (quarry-mention, quarry-activity)
	To       string         // subscriber id — a user, or "repo:<name>" topic
	Email    string         // an address instead of a subscriber: invites go to people who have no account yet
	Payload  map[string]any // repo, actor, summary, link
	TxnID    string         // idempotency key (dedups replays)
}

// EventPlane publishes one vocabulary event (RFC 0005). It is an
// interface here for the same reason the others are: the api package
// knows the vocabulary, not NATS.
type EventPlane interface {
	Raise(kind evt.Kind, repo string, payload map[string]any) error
}

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

// ProvisionPlane keeps notify's subscribers and topic membership in
// step with quarry's own accounts and grants (RFC 0011 §7). Both calls
// are best-effort in the same sense fireNotify is: a notify outage must
// never fail the account or grant op that raised it.
type ProvisionPlane interface {
	// EnsureSubscriber creates account as a notify subscriber if it is
	// not already one.
	EnsureSubscriber(ctx context.Context, account string) error
	// SetTopicMember adds or removes account from repo's activity topic.
	SetTopicMember(ctx context.Context, repo, account string, member bool) 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
}

// mentionsIn is mentions filtered to handles known returns true for
// (RFC 0011 §4): a handle that names nobody, or names somebody with no
// standing on this repository, renders as plain text and fires
// nothing. A nil known filters nothing, matching every other nil-Deps
// default in this file.
func mentionsIn(text string, known func(handle string) bool) []string {
	all := mentions(text)
	if known == nil {
		return all
	}
	out := make([]string, 0, len(all))
	for _, h := range all {
		if known(h) {
			out = append(out, h)
		}
	}
	return out
}

// anyCapLetters is a superset of every capability letter this forge
// grants (RFC 0004 §4's fossil-compat table, plus 0011's chat cap C).
// mentionKnown uses it only to test that a CapSet is NONEMPTY — "has
// any standing at all" — never to test for one specific letter, so an
// extra, never-granted letter here is harmless.
const anyCapLetters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

// mentionKnown resolves RFC 0011 §4's "known" test for repo: a handle
// survives only if it names a real account (RFC 0008's grants are the
// oracle, so a fictitious name gains nothing merely by resembling one)
// that has been given SOME capability on this repository — which may be
// nothing more than the repo's own public-read default, and that is
// enough: a private or members-only repo instead grants that default to
// nobody, so only its explicitly-granted accounts pass. A forge with no
// accounts plane or no CapsFor filters nothing, matching every other
// nil-Deps default: no accounts configured means nothing to check.
//
// d.Accounts.Accounts() loads the whole roster on every call — one per
// chat.post or forum write that mentions anyone — which is O(accounts)
// per post. Accepted for Phase 0: forge rosters here are small, and the
// alternative (an indexed handle lookup) is not worth building until a
// forge's account count makes it matter.
func (d Deps) mentionKnown(repo string) func(string) bool {
	if d.Accounts == nil {
		return func(string) bool { return true }
	}
	accts, err := d.Accounts.Accounts()
	if err != nil {
		return func(string) bool { return true }
	}
	exists := make(map[string]bool, len(accts))
	for _, a := range accts {
		exists[a.Name] = true
	}
	return func(handle string) bool {
		if !exists[handle] {
			return false
		}
		if d.CapsFor == nil {
			return true
		}
		return d.CapsFor(handle, repo).HasAny(anyCapLetters)
	}
}

// provision runs a ProvisionPlane call best-effort: never blocks the op
// that raised it, never fails it. Mirrors fireNotify's shape exactly,
// because it is the same policy (RFC 0011 §7 vs. 0001 §5) — a notify
// outage must not fail the account or grant op that noticed the need.
func (d Deps) provision(fn func(ctx context.Context) error) {
	if d.Provision == nil {
		return
	}
	go func() {
		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		if err := fn(ctx); err != nil {
			log.Printf("notify: provision: %v", err)
		}
	}()
}

// 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) {
	// Webhooks are NOT queued here. Every activity event is now raised
	// on RFC 0005's subject — by the fold for anything the DAG carries,
	// by the op for anything it does not — and the webhook dispatcher
	// fans out from that one stream. Queueing here as well would deliver
	// twice, and queueing here INSTEAD is what used to make a change's
	// webhook depend on whether it arrived through the forge or a push.
	if e.Workflow == "quarry-activity" && d.Events != nil && e.Kind.Valid() {
		if repo, _ := e.Payload["repo"].(string); repo != "" {
			if err := d.Events.Raise(e.Kind, repo, e.Payload); err != nil {
				log.Printf("events: raise %s: %v", e.Kind, err)
			}
		}
	}
	// RFC 0011 §5: the attention stream is written FIRST, and whether
	// or not notify is configured. The order is the point — an open tab
	// is told by quarry, not by a round trip through a companion — and
	// so is the independence: a forge running no notify at all still
	// tells people the things addressed to them.
	//
	// Both deliveries ride ONE goroutine, in that order. Publishing
	// inline would put a broker round trip (a five-second PubAckWait
	// when the broker has stopped acknowledging) on the write path,
	// once per recipient: an op must never pay for telling somebody
	// about itself.
	who, ae, personal := attentionOf(e)
	if !personal && d.Notify == nil {
		return
	}
	go func() {
		if personal {
			d.attend(who, ae)
		}
		if d.Notify == nil {
			return
		}
		// RFC 0011 §6's per-repository mute, applied HERE and only
		// here: quarry implements "mute" by not triggering. It is
		// checked after the attention publish above on purpose — a
		// person who muted a repository still finds its events in
		// their own inbox when they go looking; what they turned off
		// is being told about them.
		//
		// Only a PERSON-addressed trigger can be muted. A topic
		// trigger fans out to everybody on the topic, so there is no
		// one account whose preference could answer for it; muting a
		// repository's activity is a topic membership question and
		// belongs to forum.subscribe, not here.
		if repo, _ := e.Payload["repo"].(string); personal && d.muted(who, repo) {
			return
		}
		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)
		}
	}()
}

// CancelTag retracts a tag through the same lease holder WriteTag uses.
// Fossil's log is append-only: this appends a cancelling artifact, and
// the index reads the newest row for the name.
func (r *Repo) CancelTag(name, checkin string) error {
	if err := r.write(func() error {
		return r.Zit.TagCancel(name, checkin)
	}); err != nil {
		return err
	}
	return r.synced()
}

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

// settleSynced is synced() for an operation that WRITES A MERGE.
//
// synced() swallows ErrStalled, which is right for a read: a page may
// lag and must only never lie about it. It is wrong for a settle. A
// settle folds first precisely so it knows what it is settling, and a
// stalled ingest means the fold did not happen — so the accept goes on
// to read a branch tip that is hours old and merges onto it.
//
// That is ticket b7c678a5, and it cost four hours of a wrong-looking
// repository: landing.accept wrote a merge whose first parent was the
// trunk tip as of 18:15Z while thirteen check-ins had landed since,
// trunk grew a second leaf, the next accept built on the stale one,
// and every file those thirteen added vanished from the tree, the doc
// route and the rendered pages. The landing said behind:0 throughout,
// because behind is counted from the same index that had not moved.
//
// Nothing was destroyed and nothing needed recovering. What was lost
// was the forge telling the truth about its own trunk.
func (r *Repo) settleSynced() error {
	if r.Sync == nil {
		return nil
	}
	if _, err := r.Sync(); err != nil {
		if errors.Is(err, ingest.ErrStalled) {
			return fmt.Errorf("the index has not caught up with the store, so this landing "+
				"cannot be settled from what it can see — try again once ingest recovers: %w",
				ops.ErrUnavailable)
		}
		return err
	}
	return nil
}

// 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.
// ChatRefStore records that chat named an artifact, and answers how
// often and how recently. Pruned with the channel window, which is why
// it is not the index's durable MentionRef and must never be shown as
// if it were.
// chatRefRow is the "chat" line an artifact page shows: how many
// messages in the CURRENT window named it, and the latest one to land
// on. Window-bounded is the whole point, so the row says so on the page
// rather than pretending to be a durable count.
type chatRefRow struct {
	Count int    `json:"count"`
	Seq   uint64 `json:"seq"`
	At    string `json:"at"`
}

// chatRefRowFor answers the row; a zero Count is "nothing named this",
// which is a fact a page renders as silence. A value rather than a
// pointer because the ops spec reflector describes value structs and
// nothing here needs to distinguish absent from empty.
func (d Deps) chatRefRowFor(repo, target string) chatRefRow {
	if d.ChatRefs == nil || target == "" {
		return chatRefRow{}
	}
	count, seq, at, err := d.ChatRefs.ChatRefs(repo, target)
	if err != nil || count == 0 {
		return chatRefRow{}
	}
	return chatRefRow{Count: count, Seq: seq, At: at}
}

type ChatRefStore interface {
	RecordChatRef(repo, target string, seq uint64, at string) error
	PruneChatRefs(repo string, firstSeq uint64) error
	ChatRefs(repo, target string) (count int, lastSeq uint64, lastAt string, err error)
	// The reply rows (RFC 0011 §14's "3 replies →"). They live here
	// rather than in a channel scan because a preview card is hovered
	// constantly and anonymously, and because the card and chat.get
	// counting from one source is the only way they agree.
	RecordChatReply(repo string, refSeq, seq uint64) error
	DropChatReply(repo string, seq uint64) error
	PruneChatReplies(repo string, firstSeq uint64) error
	ChatReplyCount(repo string, refSeq uint64) (int, error)
}

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)
	// StagedMine is one author's OWN pending submissions. Separate
	// from StagedPending because reading the queue is the moderation
	// letter's business and reading your own held post is not.
	StagedMine(repo, author 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 records one action ABOUT somebody: `actor` is who did
	// it, `about` is who it was done to. Naming the subject is what lets
	// the log answer "is this about me?" — Target cannot, because it is
	// a staged id or a post hash and an approved post has left the
	// staged table.
	AppendModLog(repo, actor, action, target, reason, about string) error
	ModLog(repo string, limit int) ([]oltp.ModEntry, error)
	// ModLogAbout is the author's view: only the entries about them.
	ModLogAbout(repo, about 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
	// Read-state: which threads an account has seen, and when. Read is
	// a comparison against the thread's last reply, never a flag.
	MarkForumRead(repo, account, thread, lastReply string) error
	MarkAllForumRead(repo, account string) error
	ForumRead(repo, account string, threads map[string]string) (map[string]bool, error)
	// RecordMention records a mention BEFORE it is raised (RFC 0011
	// §4), which is what makes chat.mentioned an operational event
	// backed by a row rather than a fact only notify remembers. ref is
	// "chat:<at>" or "forum:<hash>"; idempotent on (repo, ref, who).
	RecordMention(repo, ref, who, by string) error
	// Chat read-state (RFC 0011 §3): the forum_read shape narrowed to
	// one row per (repo, account) — a channel has no threads, only a
	// sequence.
	MarkChatRead(repo, account string, seq uint64) error
	ChatReadSeq(repo, account string) uint64
	ChatReadAll(account string) (map[string]uint64, error)
}

// 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.
// StampStore is forge-local stamp (0001 §8). Stamp is per ACCOUNT,
// not per contributor: someone who has never authored an artifact can
// still say a repository is worth attention.
type StampStore interface {
	SetStamp(repo, account string, on bool) (bool, error)
	Stamped(repo, account string) bool
	StampCount(repo string) int
	StampedBy(account string) ([]string, error)
}

type stampSetIn struct {
	Repo string `json:"repo" path:"repo"`
	// On is the desired state, not a toggle. A toggle double-fires on a
	// retried request and lands on the opposite of what the reader
	// clicked; stating the target makes the op idempotent.
	On bool `json:"on"`
}

type stampSetOut struct {
	Stamped bool `json:"stamped"`
	Count   int  `json:"count"`
}

type stampListIn struct {
	User string `json:"user" query:"user"`
}

type stampListOut struct {
	Repos []string `json:"repos"`
}

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
	// SyncTokens issues the push credential an account proves itself
	// with (internal/oltp/synctoken.go). nil on a forge without an
	// OLTP, where pushes are not gated by accounts at all.
	SyncTokens SyncTokens
	Repos      *Repos
	Chat       ChatPlane
	// ZitVersion is what `zit version` said at startup, for meta.version.
	ZitVersion string
	// Tracker names the forge's own tracker repository, if any.
	Tracker string
	// Companions is RFC 0015's binding table: which separate store
	// holds a repository's forum. nil means the forum is always in the
	// repository, which is a forge without an OLTP.
	Companions CompanionStore
	// Origin is the public URL the forge lives at (https://host, no
	// trailing slash), from --origin. Every link the forge writes into
	// an email, a push or a notify payload starts with it; unset, links
	// stay relative to whatever host the reader came in on.
	Origin string
	// Pending names repositories the forge knows about but has not
	// finished registering — the listener binds first, registration
	// follows. A request for one answers ErrStarting, not not-found.
	Pending func() []string
	// Changelog reads the forge's CHANGELOG.md (from the tracker
	// repository's trunk, in the composition root); nil means the forge
	// has none and /changelog answers not found.
	Changelog func(ctx context.Context) ([]byte, error)
	// Mail sends invites; nil when no mail provider is configured.
	Mail   MailPlane
	Notify NotifyPlane
	// Provision is RFC 0011 §7's job: quarry owns the mapping from its
	// own identity and grants to notify's subscribers and topic
	// membership, so a trigger reaches somebody without an operator
	// having typed a curl command first. nil disables provisioning
	// entirely, which is how a forge with no notify plane simply
	// provisions nothing.
	Provision ProvisionPlane
	// Attention is RFC 0011 §5's person-addressed stream. It is fired
	// BEFORE notify and independently of it, so an open tab hears from
	// quarry rather than through a round trip, and so a forge with no
	// notify at all still tells people what was addressed to them. nil
	// disables it, which is a forge with no event plane.
	Attention AttentionPlane
	// AttnRead is the read pointer the local unread count measures
	// from; nil means nothing is ever marked read.
	AttnRead AttentionStore
	// Inbox is the notify-backed inbox `inbox.count` prefers when it
	// exists — that is the inbox a person can open and clear. nil falls
	// back to the local attention tail.
	Inbox InboxPlane
	// Prefs is the forge-local half of a person's notification
	// preferences (RFC 0011 §6): the toast and desktop switches, and
	// the per-repository mute quarry implements by not triggering. nil
	// means this forge keeps no preferences, which is one with no OLTP.
	Prefs AccountPrefStore
	// PushDevices remembers which browsers an account turned push on
	// in. nil means the preferences page lists none; the subscriptions
	// themselves live in notify either way.
	PushDevices PushDeviceStore
	// ChatRobots is RFC 0011 §10's per-repository switch; nil means the
	// robot cannot be configured, which is a forge with no OLTP.
	ChatRobots ChatRobotStore
	// CapsFor resolves effective capabilities the same way the router's
	// enforcement does (RFC 0004 §4) — used where an op needs to ask
	// "does this account have any standing here" without going through
	// a request's own auth context, e.g. RFC 0011 §4's mention filter.
	// nil disables the check, which is how a forge with no accounts
	// plane has nothing to filter.
	CapsFor CapsFor
	// Teams is RFC 0018's roster. nil disables the team ops, which is
	// how a forge with no teams configured simply has no team surface.
	Teams TeamStore
	// Roster is the per-repository capability STRING's write seam — the
	// one path account.caps changes it by, and the source of the
	// before-string every account.* event needs. nil disables
	// account.caps and silences the events, which is a forge with no
	// accounts to speak of.
	Roster CapsWriter
	// Events publishes RFC 0005's vocabulary on
	// quarry.evt.<repo>.<noun>.<action>. nil disables it, which is how a
	// forge with no event plane simply raises nothing.
	Events EventPlane
	// 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
	// CIDecline carries an admin's answer to the CI tab's offer to help
	// set CI up. nil means nobody can have answered, and the navigation
	// then behaves as though nobody has — offering, not hiding.
	CIDecline CIDecline
	// 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
	// Protect is RFC 0007's policy: which branches only owners may
	// change. nil protects nothing, which is every forge until an
	// administrator adds a row.
	Protect ProtectStore
	// Checks is RFC 0009's required-checks policy: which check names
	// must be green on a target branch before an accept proceeds. nil
	// requires nothing.
	Checks ChecksStore
	// Auto holds armed auto-accepts (RFC 0009 §verdicts, the
	// follow-through): a landing a decider armed lands itself when its
	// required checks go green. nil disables arming, which is a forge
	// without an OLTP.
	Auto AutoStore
	// Gate is the bypass tripwire's memory (RFC 0007): the accepts the
	// forge itself wrote, and the findings when a protected branch
	// moved without one. nil disables both halves.
	Gate GateStore
	// Local is the local-ticket tier (docs/local-tickets.md): the
	// intake queue that stays out of the artifact log until an admin
	// promotes it. nil disables the tier, which is a forge without an
	// OLTP.
	Local LocalTicketStore
	// External is RFC 0009's door for CI quarry does not run: reported
	// checks and the reporter tokens that may write them. nil closes
	// the door, which is a forge with no foreign CI.
	External ExternalCI
	// CITrigger re-runs a repository's pipelines at a check-in (empty
	// means the tip). nil means no engine is configured.
	CITrigger func(repo, checkin string) error
	// ValidateCIYAML asks the engine whether a ci.yml compiles,
	// returning its diagnostics in words. nil skips engine validation
	// (a forge without the CI door still takes the file; the engine
	// judges it at register time).
	ValidateCIYAML func(name string, spec []byte) ([]string, error)
	// SavePipeline commits one pipeline file to a fresh branch on the
	// repository's store and returns the check-in. nil disables the
	// editor's save.
	SavePipeline func(repo, branch, user, message, path string, body []byte) (string, error)
	// 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
	// Owners is the registry's record of who holds each repository and
	// where it appears. nil disables repo.claim.
	Owners RepoOwnership
	// Acts is the forge journal: what an operator did to a repository
	// somebody else holds. nil keeps no journal.
	Acts ForgeActs
	// Stamp is the forge's approval signal, forge-local OLTP like
	// Votes. nil disables praising entirely, which is how a forge
	// without an OLTP simply has no stamp.
	Stamp StampStore
	// 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
	// Replies is what RFC 0014 Phase 1 remembers about replies that
	// arrive through notify. nil means this forge takes none, which is
	// a forge without an OLTP.
	Replies ReplyStore
	// ChatRefs is the chat→artifact backlink store (RFC 0011 §14). A
	// SEPARATE seam from Forum on purpose: these rows are window-bounded
	// and a forge that runs no chat plane has no use for them, so a
	// nil here is an ordinary configuration and not a missing feature.
	ChatRefs ChatRefStore
	// ChatBridges is RFC 0011 §9's opt-in Fossil chat bridge
	// configuration; nil disables the op, which is a forge with no
	// OLTP and so nothing to configure it in.
	ChatBridges ChatBridgeStore

	// 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
	// Visibility names a repository's RFC 0008 preset — "Public project",
	// "Members only", "Private" or "Custom" — from its category strings.
	// nil leaves the field off.
	Visibility func(repo string) string
	// Deletable reports which repositories this node created and may
	// therefore destroy.
	Deletable func(repo string) bool
	// Mirrorable reports the EFFECTIVE answer for a repository: whether
	// other people may actually keep a copy that follows it right now.
	// This already folds in the private veto — a private repository
	// answers false here even when its own flag says true — because a
	// reader of repo.get should not have to also check Private to know
	// whether mirroring would work. nil means "every repository allows
	// it", the same default the column carries.
	Mirrorable func(repo string) bool
	// Descriptions supplies each repository's one-line summary for the
	// listing; nil simply renders none.
	// TicketTier answers where a repository's new tickets land:
	// "fossil" for one that keeps its tracker in the code history,
	// anything else for the forge's own tier (docs/local-tickets.md).
	// nil means the forge default, which is the safe one.
	TicketTier   func(repo string) string
	Descriptions func() map[string]string
	// RepoMeta supplies the settings-held About fields per repository.
	// Like Descriptions, nil means "this node has no registry".
	// SettingsLog reads the durable record of who changed a
	// repository's settings: which field, from what, when. Nil on a
	// forge with no registry database, where the op answers as
	// unavailable rather than as an empty history.
	SettingsLog func(repo string, limit int) ([]oltp.SettingsChange, error)
	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
	// FossilUsers keeps a repository's synced fossil user table as a
	// reference list; nil disables the repo.fossil.users ops.
	FossilUsers FossilUserStore
	// ProjectCode is a served repository's project code, which the
	// fossil login signature is derived from.
	ProjectCode func(repo string) (string, error)
	// FetchUsers asks a fossil server for its user table; nil uses
	// xfer.FetchUsers. A seam so tests need no fossil server.
	FetchUsers func(peer, projectCode, login, password string) ([]xfer.RemoteUser, error)
	// Buckets is RFC 0016's binding table: where a repository's
	// uploads go. nil means no bucket can be bound, which is a forge
	// without an OLTP.
	Buckets BucketStore
	// Avatars is the uploaded-mark table. nil is a forge that keeps
	// none, and everything keeps the identicon derived from its name.
	Avatars AvatarStore
	// Attachments is RFC 0016 Phase 3's forge-local state: upload
	// tickets, holds and the binding's policy switches. nil means no
	// uploads and nothing held, which is a forge without an OLTP.
	Attachments AttachmentStore
	// Errors is the server's error journal (errlog.Default in the real
	// wiring); nil reports no errors, which suits tests.
	Errors *errlog.Ring
	// Hooks is the webhook admin surface: what was delivered, and the
	// two ways to send again. Nil on a forge with no delivery worker.
	Hooks HookAdmin
	// Lanes reports the admission lanes this process owns beyond the
	// two internal ones — ingest and bulk live in the composition root,
	// which is the only place that holds them. Nil is a forge with no
	// extra lanes, not a broken one.
	Lanes func() []zitcli.Lane
	// ErrorRates answers how often each signature has occurred lately,
	// as bucket counts oldest-first. Separate from Errors because the
	// ring is in memory and cannot know: only the durable half keeps a
	// row per occurrence.
	ErrorRates func(buckets int, span time.Duration) (map[string][]int, error)
	// ForgetError discards one signature from BOTH halves of the
	// journal — the in-memory ring and the durable table. Both, because
	// clearing one and not the other is worse than clearing neither:
	// the ring would keep showing a count the table no longer has, or
	// the table would seed the count straight back on the next restart.
	// Nil leaves the journal append-only, which suits tests.
	ForgetError func(sig string) error
}

// 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
	// SetOperator marks an account machine-operated and records who
	// answers for it; "" clears the mark (oltp/agent.go).
	SetOperator(name, operator string) 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)
}

// SyncTokens issues and withdraws push credentials. Minting returns
// the token the ONE time it is readable; nothing here can read an
// existing one back, which is the property that lets a page say a
// credential exists without being a place to steal it from.
type SyncTokens interface {
	MintSyncToken(user string) (string, error)
	RevokeSyncToken(user string) error
	SyncTokenIssued(user string) (string, bool)
}

// meSyncTokenOut carries a freshly minted push credential. This is the
// only shape in the API that ever holds one.
type meSyncTokenOut struct {
	User  string `json:"user"`
	Token string `json:"token"`
}

// meSyncTokenStateOut says that a credential exists, not what it is.
type meSyncTokenStateOut struct {
	Held    bool   `json:"held"`
	Created string `json:"created,omitempty"`
	// Available separates "this forge cannot issue one" from "you do
	// not have one". Both used to answer held:false, so a caller could
	// not tell a missing credential from a missing feature — and a page
	// drawing a mint button from that answer would offer a control that
	// cannot work.
	Available bool `json:"available"`
}

// 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"`
	// Private-beta fields: where the person is reachable, whether an
	// admin has shut the account (when), whether the invite is still
	// unclaimed, and the last sign-in.
	Email string `json:"email,omitempty"`
	// EmailPending is an address typed but not yet confirmed; "" when
	// there is none in flight (see AccountEmail in email.go).
	EmailPending string `json:"emailPending,omitempty"`
	Disabled     string `json:"disabled,omitempty"`
	Pending      bool   `json:"pending,omitempty"`
	LastLogin    string `json:"lastLogin,omitempty"`
	// Operator names the person answerable for a MACHINE-OPERATED
	// account; empty means a person's own. Its non-emptiness is what
	// makes the account an agent — one field, because "a machine wrote
	// this" without "and X runs it" tells a reader to distrust the
	// words without telling them who to ask.
	Operator string `json:"operator,omitempty"`
}

// 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
	// Chat is the repository's chat setting; the tab and channel exist
	// only when this is on AND the forge runs a chat plane.
	Chat bool
	// Mirrorable is the STORED flag, not the effective answer.
	//
	// Deps.Mirrorable reports the effective one — MayMirror, which is
	// false for every private repository whatever the flag says — and
	// that is the right answer to "may somebody mirror this". It is the
	// wrong thing to write BACK: a partial update that read the
	// effective value while the repository was private, then set it
	// public, laundered "false because private" into "false because
	// asked", and the repository came out of the detour public,
	// unclonable, and saying nothing was wrong.
	Mirrorable bool
}

// 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 changes the settings a caller NAMED, leaves every other one
	// untouched, and records who did it. See RepoEdit.
	//
	// Chat is one of those settings rather than a call of its own: a
	// second write path would need its own actor and its own audit
	// record, and the two could then disagree about what a single save
	// did.
	Update(name string, edit RepoEdit, by string) error
	// Imports reports repositories that are being copied, or whose copy
	// failed. These are NOT in the served set — there is nothing to
	// serve yet — so no other listing can show them, which is precisely
	// why an import used to be invisible.
	Imports() ([]RepoImport, error)
}

// RepoImport is one repository mid-copy, or one whose copy failed. Bytes
// is what has landed on disk so far: honest progress without teaching
// quarry the sync protocol, and the only number available while another
// server decides how fast to send.
type RepoImport struct {
	Name    string `json:"name"`
	URL     string `json:"url"`
	State   string `json:"state"` // cloning | failed
	Error   string `json:"error,omitempty"`
	Started string `json:"started"`
	Bytes   int64  `json:"bytes"`
}

// RepoEdit is a settings patch: a nil field is not being changed.
//
// Positional arguments could not express "leave this alone", so the one
// write path read every setting, overlaid the named ones, and wrote the
// whole row back. That made every partial update a read-modify-write
// over the entire settings row, with two consequences. A value that was
// wrong when it was READ became durably wrong when it was written --
// which is how a repository that nobody asked to make private became
// private, the read having returned the effective mirrorable rather
// than the stored one. And two admins saving different panels in the
// same moment each wrote back their own stale copy of the other's
// field, so the later save silently reverted the earlier one with no
// error and no trace.
//
// A patch has no before-image to go stale, so neither failure has
// anywhere to happen: an unnamed field is not written at all.
type RepoEdit struct {
	Description *string
	// Visibility is "public" or "private" -- see visibilityOf.
	Visibility *string
	Homepage   *string
	// Topics is the space-separated list, already normalised.
	Topics     *string
	Chat       *bool
	Mirrorable *bool
}

// Any reports whether this patch changes anything, so a caller that
// named no settings does no write rather than an empty one.
func (e RepoEdit) Any() bool {
	return e.Description != nil || e.Visibility != nil || e.Homepage != nil ||
		e.Topics != nil || e.Chat != nil || e.Mirrorable != nil
}

// 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"`
	// Bridged says this store has a Fossil chat bridge (RFC 0011 §9)
	// and ChatBridge is it. Two fields rather than a pointer because
	// the ops spec reflector documents concrete shapes only, and a
	// zero row would otherwise render as a bridge to nowhere. It rides
	// on the mirror row because the bridge is configured per mirror
	// and every surface showing one wants the other beside it. Never
	// carries the credential.
	Bridged    bool       `json:"bridged,omitempty"`
	ChatBridge ChatBridge `json:"chatBridge"`
}

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

// 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 {
		// A mirror whose store is not here is still a run that failed:
		// recorded, so it is due again at its interval, not every tick.
		if d.Mirrors != nil {
			if rerr := d.Mirrors.RecordMirrorRun(m.Repo, err); rerr != nil {
				log.Printf("mirror: %s: recording run: %v", m.Repo, rerr)
			}
		}
		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"
}

// roleBefore reads an account's current role on a repository as the
// AccessChanged "before" argument — "" when it holds no explicit
// grant there, which Grant and Revoke both treat as "no access" rather
// than an error, since that is the ordinary starting point for a grant.
func (d Deps) roleBefore(account, repo string) string {
	if d.Accounts == nil {
		return ""
	}
	grants, err := d.Accounts.GrantsFor(account)
	if err != nil {
		return ""
	}
	return grants[repo]
}

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
}

// checkinContextSpan is how many neighbours to show each side. Fossil
// shows a handful; enough to place the check-in in its day without
// turning the page into a second timeline.
const checkinContextSpan = 3

// manifestViewMax caps the store read behind the manifest view. Real
// manifests run to ~85KB on fossil, almost all of it the file list; 4MB
// is far above anything observed and still refuses to buffer a
// pathological artifact into memory.
const manifestViewMax = 4 << 20

// cardNote names each card type, so the view explains itself rather
// than assuming its reader has the format RFC open. Keyed by the card
// letter (§4.7).
var cardNote = map[string]string{
	"B": "baseline manifest this one deltas against",
	"C": "the check-in comment",
	"D": "when it was made",
	"F": "a file and its blob",
	"J": "a ticket field change",
	"K": "the ticket this artifact belongs to",
	"L": "the wiki page title",
	"M": "an artifact belonging to this cluster",
	"N": "the mimetype of the body",
	"P": "the parent check-in, oldest first",
	"Q": "a cherry-picked change",
	"R": "checksum of the whole tree at this check-in",
	"T": "a tag applied by this artifact",
	"U": "who made it",
	"W": "the body text and its length",
	"Z": "checksum of this artifact itself",
}

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).
// link makes a site path absolute when the forge knows its public
// origin. A relative link is right on the web and wrong in an email or
// a push, which is why the origin exists.
func (d Deps) link(path string) string {
	if d.Origin == "" {
		return path
	}
	return strings.TrimSuffix(d.Origin, "/") + path
}

// base is the origin to build a link from: the configured one, else
// what the caller saw (the web passes its request origin).
func (d Deps) base(fallback string) string {
	if d.Origin != "" {
		return strings.TrimSuffix(d.Origin, "/")
	}
	return strings.TrimSuffix(fallback, "/")
}

func (d Deps) repo(name string) (*Repo, error) {
	if r, ok := d.Repos.Get(name); ok {
		return r, nil
	}
	if d.Pending != nil {
		for _, p := range d.Pending() {
			if p == name {
				return nil, fmt.Errorf("repository %q is %w", name, ops.ErrStarting)
			}
		}
	}
	// The web addresses a repository as /r/{owner}/{repo}; the API keys
	// it by the bare name. A caller who carried the web's spelling into
	// the API got "no repository" for a repository that exists, and had
	// to guess the difference (4bc7035f, G-1). Say it.
	if i := strings.LastIndex(name, "/"); i >= 0 && i < len(name)-1 {
		return nil, fmt.Errorf("no repository %q: the API names a repository by its bare name, without the owner; try %q: %w",
			name, name[i+1:], ops.ErrNotFound)
	}
	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"`
	// Zit is the version of the zit binary the forge shells out to —
	// half of "which build is this" when something misbehaves.
	Zit string `json:"zit,omitempty"`
	// Ready says every repository is registered and the forge answers
	// for all of them. After a restart, registration runs serially and
	// took 65–75s for 24–26 stores; for that whole window every sync
	// and every repository page answers "still starting" (503) while
	// the process is up and the root URL answers 200. Nothing exposed
	// could tell STARTING from BROKEN, so a deploy script health-checked
	// the root, printed success, and left the operator to rediscover
	// the outage by hand (b81ae623). Starting names what is still to
	// come, so a probe can say how far along it is.
	Ready    bool     `json:"ready"`
	Starting []string `json:"starting,omitempty"`
}

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

// storeNoCheckoutNote is the fallback for a zit too old to answer
// settings/config on a bare store. zit#153 shipped that support, so a
// current forge never shows this; an operator on an older zit sees why
// rather than an empty editor.
const storeNoCheckoutNote = "This section needs a zit new enough to read settings and configuration on a served store (zit#153). Upgrade zit and it fills in."

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

type configImportIn struct {
	Repo  string `json:"repo" validate:"required,max=64"`
	File  string `json:"file" validate:"required,max=8388608"`
	Merge bool   `json:"merge"`
}

type settingsSetIn struct {
	Repo  string `json:"repo" validate:"required,max=64"`
	Name  string `json:"name" validate:"required,max=64"`
	Value string `json:"value" validate:"max=4096"`
}

// settingRow is one fossil setting: its value in force, and whether it
// is one fossil lets a repository carry versioned in the tree.
type settingRow struct {
	Name        string `json:"name"`
	Value       string `json:"value"`
	Versionable bool   `json:"versionable"`
}

// settingsListOut carries the store's settings, or supported=false
// with a note when the store cannot answer yet (zit#153).
type settingsListOut struct {
	Supported bool         `json:"supported"`
	Note      string       `json:"note,omitempty"`
	Settings  []settingRow `json:"settings"`
}

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

type repoInfo struct {
	Name string `json:"name"`
	// Visibility is the RFC 0008 preset the category strings are.
	Visibility string `json:"visibility,omitempty"`
	// 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"`
	// Forum names the companion store holding this repository's forum
	// (RFC 0015), empty when the forum is in the repository itself.
	Forum string `json:"forum,omitempty"`
	// 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"`
	// Chat is whether this repository HAS a chat channel here: the
	// forge runs a chat plane and the setting is on. ChatSetting is
	// the setting alone, for the form that edits it.
	Chat        bool `json:"chat"`
	ChatSetting bool `json:"chatSetting"`
	// 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"`
	// Mirrorable is the EFFECTIVE answer: whether other people may
	// actually keep a copy that follows this repository right now. A
	// private repository reports false here even when its own stored
	// flag is on — see Deps.Mirrorable.
	Mirrorable bool   `json:"mirrorable"`
	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"`
	// Starting: registration has not finished since the process bound.
	Starting bool `json:"starting,omitempty"`
}

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"`
	// Attach lists upload tickets begun without a target
	// (`attachment.begin` with a family) — files pasted or dropped into
	// the composer while this was written. Each is committed against
	// the new object once it exists; a refusal is reported in
	// `attachErrors` and never undoes the write.
	Attach []string `json:"attach" validate:"max=20"`
}

type wikiSaveOut struct {
	Hash string `json:"hash"`
	// AttachErrors names, one line each, the upload tickets in `attach`
	// that could not be committed. Absent when every file landed.
	AttachErrors []string `json:"attachErrors,omitempty"`
}

// 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"`
	// The newest technote. Notes are a project's news — milestones,
	// releases, decisions — and this is where a reader looks for that,
	// which is why they do not need a tab of their own (fossil's own
	// default menu has none either).
	NoteEvent   string `json:"noteEvent,omitempty"`
	NoteTitle   string `json:"noteTitle,omitempty"`
	NoteDate    string `json:"noteDate,omitempty"`
	NoteUser    string `json:"noteUser,omitempty"`
	NoteExcerpt string `json:"noteExcerpt,omitempty"`
	NoteCount   int    `json:"noteCount,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
	}
	seen := map[string]bool{}
	var names []string
	for repo := range grants {
		if repo != "" && !seen[repo] {
			seen[repo] = true
			names = append(names, repo)
		}
	}
	// RFC 0018's third route. GrantsFor reads the grant ROWS, and a
	// repository reached through a team has none — membership is a
	// union in EffectiveCaps, not a row per member per repository. So
	// somebody whose only access is a team saw an empty dashboard and,
	// with it, the page that says "you were invited to look around",
	// on a forge where they already reach things.
	//
	// Not fixed in GrantsFor, which three other callers read as the
	// literal grants they are: what role did this account have before
	// a change, what rows does the people page list, what does a claim
	// hand over. This is the one caller asking "what does this account
	// REACH", which is a different question.
	if d.Teams != nil {
		if teams, terr := d.Teams.TeamsFor(user); terr == nil {
			for _, t := range teams {
				rows, rerr := d.Teams.TeamRepos(t.Name)
				if rerr != nil {
					continue
				}
				for _, row := range rows {
					if !seen[row.Repo] {
						seen[row.Repo] = true
						names = append(names, row.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
}

// myReposWithRoles is myRepos plus what the caller IS on each one.
//
// myRepos resolves the grants and then drops the roles on the floor,
// so the dashboard listed a person's repositories with no way to tell
// the one they administer from the one they can only read — the same
// question /me's Access section answers, on the page they land on
// first (f44e0c66).
//
// A repository reached only through a TEAM has no grant row, which is
// the whole reason myRepos goes looking for them separately. Its role
// comes back empty rather than guessed, and the page says "through a
// team" instead of naming a role nobody granted.
func (d Deps) myReposWithRoles(ctx context.Context, limit int) (string, []string, map[string]string, error) {
	user, names, err := d.myRepos(ctx, limit)
	if err != nil {
		return "", nil, nil, err
	}
	roles := map[string]string{}
	if d.Accounts != nil {
		if grants, gerr := d.Accounts.GrantsFor(user); gerr == nil {
			for _, n := range names {
				if role, ok := grants[n]; ok {
					roles[n] = role
				}
			}
		}
	}
	return user, names, roles, 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"`
	// Offset is the page. A numbered pager needs random access, which a
	// cursor cannot give: "page 7" is a position, not a place you
	// arrived from. The merge is ordered by (date, hash) — a total
	// order, since hashes are unique corpus-wide — so a given offset
	// names the same events on every request.
	Offset int `json:"offset" validate:"min=0,max=1000"`
	// 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"`
	// Repo narrows the FEED to one repository. The repository list and
	// the kind counts stay whole: a facet that hid the other choices
	// would be a filter you cannot get out of.
	Repo string `json:"repo" 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"`
	// Role is what the caller IS here — the grant row's role. Empty
	// when the repository is reached through a team, which has no row
	// of its own: the page says so rather than inventing a role.
	//
	// Without it the dashboard drew the repository somebody
	// administers exactly like the one they can only read, so the page
	// a person lands on could not answer "what am I here" (f44e0c66).
	Role string `json:"role,omitempty"`
}

// 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 names a key of index.TicketOrders. Checked against that map
	// in the handler rather than spelled as an enum here, so the two
	// cannot drift: the map is already the only source of the SQL.
	//
	// An unrecognised value used to be discarded and the default used,
	// so a caller who misspelled a sort believed it had sorted and it
	// had not -- a wrong answer delivered as a successful one (G-132).
	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"`
	// Mine narrows to the tickets the caller is assigned. Assignment is
	// a TRACKER-defined column (fossil's ten do not include one), so a
	// tracker that never names an assignee simply contributes nothing
	// here — which is the honest answer, not an error.
	//
	// A flag rather than an assignee input: this is the caller's own
	// queue, and naming someone else's is a report (author, subsystem,
	// status) that the axes above already express.
	Mine bool `json:"mine"`
}

// branchTipOf names the check-in a branch points at, for cache keys that must
// change when the branch does. Empty on any failure, which disables the memo
// rather than sharing one entry between different states.
func branchTipOf(repo *Repo, branch string) string {
	if repo.Index == nil || branch == "" {
		return ""
	}
	entries, _, err := repo.Index.Timeline(index.TimelineFilter{Limit: 1, Branch: branch, Kind: "ci"})
	if err != nil || len(entries) == 0 {
		return ""
	}
	return entries[0].Hash
}

// refusalText lifts the store's own sentence out of a wrapped error, so a
// refusal reads as the store phrased it. zit writes for a human already; the
// forge has nothing to add and should not paraphrase.
func refusalText(err error) string {
	msg := err.Error()
	if i := strings.LastIndex(msg, "zit landing: "); i >= 0 {
		msg = msg[i+len("zit landing: "):]
	}
	if i := strings.IndexByte(msg, '\n'); i >= 0 {
		msg = msg[:i]
	}
	return strings.TrimSpace(msg)
}

// seesPrivate answers whether the caller holds fossil's capability e
// for this request — the letter that decides whether a ticket its own
// tracker marked private exists for them at all.
//
// It reads the CONTEXT, never the input struct. A private-ticket switch
// that could arrive as a request field would be a switch anybody could
// flip, which is the whole reason the edge carries the resolved set.
// expandAttachTarget resolves a target the caller abbreviated.
//
// AttachmentsFor matches the target exactly, but the pages of this
// forge link tickets and technotes by their first few hex digits, so
// the endpoint is handed a prefix far more often than a full id. An
// abbreviated ticket matched nothing and got the empty list that a
// ticket with no files gets — indistinguishable from a correct
// answer, which is what made it expensive rather than merely wrong.
// It cost a real measurement: the endpoint was called with a prefix,
// answered [], and a tester's entirely valid finding was reported back
// to them as not reproducible (7a0f3c9f).
//
// The rule is deliberately narrow: an exact hit always wins, and this
// runs only when the literal target answered nothing. A wiki page is a
// target too, and a page whose name happens to read like a hex prefix
// must keep its own attachments rather than be shadowed by a ticket
// that merely starts with the same digits. Nothing that answers
// correctly today can change; only an empty answer can become a full
// one.
//
// Privacy follows ticket.get exactly. Without fossil's e a private
// ticket does not resolve at all, so a caller cannot confirm one
// exists by watching its attachments appear.
func (d Deps) expandAttachTarget(ctx context.Context, repo *Repo, target string) string {
	if target == "" || len(target) >= 40 {
		return target
	}
	if t, ok, err := repo.Index.TicketByPrefixFor(target, seesPrivate(ctx)); err == nil && ok {
		return t.ID
	}
	// Events cover the other two kinds the doc names. EventByPrefix
	// answers a wiki edit with its PAGE NAME and a technote with its
	// event id — which is the key each one's attachments hang on, so
	// the hash a page linked resolves to the target they were filed
	// against.
	if _, ref, ok, err := repo.Index.EventByPrefix(target); err == nil && ok && ref != "" {
		return ref
	}
	return target
}

func seesPrivate(ctx context.Context) bool {
	return ops.CapsOf(ctx).HasAny("e")
}

// landingSay is the one write path behind comment, reject and accept. All
// three append to the record's thread; accept additionally merges, and zit
// owns that ordering (merge first, then record — a failure after the merge
// leaves merged work under an open landing, which one command settles, where
// the reverse would leave an accepted record over an unmerged branch: a lie
// in a log that cannot retract one).
// stackBlock says why the landing id cannot land NOW because of the
// stack it sits in, or "" when nothing in the stack stops it. The one
// producer of that fact: landing.get reports it as mayLandReason and
// landing.accept refuses with it, so the two cannot disagree.
//
// It reads the chain from StrataOf and nowhere else. An earlier version
// found "the base" itself — the landing this one targets — which is the
// rung directly below, and only the base when the chain is two tall. On
// R <- A <- B with R rejected, it told B that A was rejected. A is open;
// the thing for B to do is wait for A.
//
// So: a rung above the bottom waits for the rung below it, whatever is
// under that. The bottom rung of a chain whose base was rejected is the
// one that must move, and it is told to retarget onto the rejected
// base's own target.
func stackBlock(all []index.LandingSummary, id string) string {
	st := index.StrataOf(all, id)
	var me *index.StackSegment
	for i := range st.Segments {
		if st.Segments[i].ID == id {
			me = &st.Segments[i]
		}
	}
	if me == nil || me.Landable {
		return ""
	}
	if me.Position > 1 {
		for _, sgm := range st.Segments {
			if sgm.Position == me.Position-1 {
				return fmt.Sprintf("stacked on %s; land it first", short(sgm.ID))
			}
		}
		return "stacked; land the base first"
	}
	if st.Broken == "base rejected" {
		for _, l := range all {
			if l.ID == st.Base {
				return fmt.Sprintf("base %s was rejected; retarget this landing to %s", short(l.ID), l.Target)
			}
		}
		return "the base was rejected; retarget this landing"
	}
	return ""
}

// short is the prefix pages link landings by.
func short(id string) string {
	if len(id) > 12 {
		return id[:12]
	}
	return id
}

func (d Deps) landingSay(ctx context.Context, in landingSayIn, verb string) (landingSayOut, error) {
	if err := d.refuseUnlessWritable(in.Repo); err != nil {
		return landingSayOut{}, err
	}
	repo, err := d.repoFor(ctx, in.Repo)
	if err != nil {
		return landingSayOut{}, err
	}
	l, ok, err := repo.Index.LandingByPrefix(in.ID)
	if err != nil {
		return landingSayOut{}, err
	}
	if !ok {
		return landingSayOut{}, fmt.Errorf("no landing %q: %w", in.ID, ops.ErrNotFound)
	}
	// A settled record does not reopen, and asking twice must be
	// harmless. Both halves of that were wrong here.
	//
	// The state came from the INDEX, which lags the store by an ingest
	// round. A second accept arriving inside that window read "open",
	// passed, and wrote another state reply — and zit does not refuse
	// it either: an already-contained branch still gets a reply saying
	// "accepted", with "nothing to merge" in the body. Four of those
	// stacked up on one landing before this was noticed.
	//
	// So: fold first, then decide. A settle is a deliberate, rare act
	// and can afford one ingest round to be sure what it is settling.
	if verb != "comment" {
		// STRICT here, unlike everywhere else: a settle that cannot
		// fold does not know what it is settling (see settleSynced).
		if err := repo.settleSynced(); err != nil {
			return landingSayOut{}, err
		}
		fresh, ok, err := repo.Index.LandingByPrefix(in.ID)
		if err != nil {
			return landingSayOut{}, err
		}
		if ok {
			l = fresh
		}
		// Already settled is the END STATE the caller asked for, so
		// this succeeds and writes nothing. An error here would make a
		// double-press look like a failure when the landing is exactly
		// as the presser wanted it.
		if l.State != "open" {
			return landingSayOut{State: l.State}, nil
		}
		// RFC 0007: accepting writes a merge onto the target, and only
		// an owner's hand may do that. Checked on the SESSION user —
		// the one fact the forge has — before anything is written.
		if verb == "accept" && d.Protect != nil && !d.Protect.MayJoin(in.Repo, ops.User(ctx), l.Target) {
			_, owners := d.protectionOf(in.Repo, l.Target)
			return landingSayOut{}, fmt.Errorf("%s is protected; owners: %s: %w",
				l.Target, strings.Join(owners, ", "), ops.ErrForbidden)
		}
		// RFC 0006: a rung above the bottom of a stack cannot land yet,
		// and a rung on a rejected base must not. zit refuses both, but
		// its refusal is a command line — "retarget it with 'zit landing
		// retarget ...'" — and quarry passed it through as the API's
		// error. Decided HERE instead, from the same predicate
		// landing.get reports as mayLandReason, so the answer a client
		// read before pressing is the answer the press gives.
		if verb == "accept" {
			if all, aerr := repo.Index.Landings("", 0); aerr == nil {
				index.AttachStrata(all)
				if why := stackBlock(all, l.ID); why != "" {
					return landingSayOut{}, fmt.Errorf("%s: %w", why, ops.ErrInvalid)
				}
			}
		}
		// RFC 0009 §verdicts: the machine's opinion gates the accept the
		// way protection does — in words, at the one write path. An
		// override is a branch owner (or a) saying why, out loud; the
		// reason lands on the record before the merge does.
		if verb == "accept" {
			if unmet := d.unmetChecks(in.Repo, l); len(unmet) > 0 {
				if in.Override == "" {
					return landingSayOut{}, fmt.Errorf("required checks not green: %s: %w",
						strings.Join(unmet, ", "), ops.ErrForbidden)
				}
				if !d.mayOverride(ctx, in.Repo, l.Target) {
					return landingSayOut{}, fmt.Errorf("overriding a required check takes a branch owner or a repository admin: %w",
						ops.ErrForbidden)
				}
				note := "Accepting over required checks (" + strings.Join(unmet, ", ") + "): " + in.Override
				if err := repo.write(func() (werr error) {
					_, werr = repo.Zit.LandingSay("comment", l.ID, author(ctx), note,
						authoredMimetype, "")
					return werr
				}); err != nil {
					return landingSayOut{}, err
				}
			}
		}
	}
	// A remark may answer an earlier post rather than the record itself
	// (zit#149 gave `landing comment` its --reply-to), so there is one
	// write path here and not two.
	//
	// The target is checked against THIS record's thread first. zit
	// refuses an out-of-thread target too, but that is zit keeping its
	// own artifacts coherent — here it is a CAPABILITY boundary: without
	// this check the field is a way to aim a write at any thread in the
	// repo under a landing's capability check, and quarry does not
	// delegate its permission model to an upstream refusal.
	parent := ""
	if verb == "comment" && in.To != "" {
		post, thread, ok, err := repo.Index.ForumPostThread(in.To)
		if err != nil {
			return landingSayOut{}, err
		}
		if !ok || thread != l.ID {
			return landingSayOut{}, fmt.Errorf("no post %q in this record: %w", in.To, ops.ErrInvalid)
		}
		parent = post
	}
	var hash string
	if err := repo.write(func() (werr error) {
		hash, werr = repo.Zit.LandingSay(verb, l.ID, author(ctx), in.Message,
			authoredMimetype, parent)
		return werr
	}); err != nil {
		// A conflict refused the merge and wrote nothing (zit#131 exit
		// 5). That is a STATE of the proposal, not a fault in the
		// request — so it comes back as an invalid-request the page can
		// render, naming the files, rather than a 500 that says the
		// forge broke.
		var conflict *zitcli.LandingConflict
		if errors.As(err, &conflict) {
			return landingSayOut{}, fmt.Errorf("%s: %w", conflict.Error(), ops.ErrInvalid)
		}
		// Every other way the store can REFUSE this — a branch already
		// contained in its target, an id it will not resolve — is a
		// statement about the proposal, not a fault in the forge. It
		// reaches the reader as its own words rather than as "something
		// went wrong serving this page", which tells them nothing and
		// is not even true.
		if errors.Is(err, zitcli.ErrNoEntry) {
			return landingSayOut{}, fmt.Errorf("%s: %w", refusalText(err), ops.ErrInvalid)
		}
		return landingSayOut{}, err
	}
	if err := repo.synced(); err != nil {
		return landingSayOut{}, err
	}
	state := l.State
	switch verb {
	case "accept":
		state = "accepted"
		// The merge this accept produced, read ONCE from the index and
		// handed to everything that names it: the answer to the caller,
		// the closing note on each ticket, and the tripwire. Three
		// readers of one fact get one producer; zit does not print the
		// merge (it prints a sentence), so the index is where it is.
		hash = ""
		if ms, merr := repo.Index.MergesForLandings([]string{l.ID}); merr == nil {
			hash = ms[l.ID].Checkin
		}
		// A landing that said it closes tickets closes them, as the
		// account whose press did it. AFTER the merge and never before:
		// the record has to be settled for the claim to be true, and a
		// ticket closed by a merge that then refused would be a lie
		// nobody went back to correct.
		d.closeNamedTickets(ctx, in.Repo, repo, l, hash)
		// The tripwire's memory: this merge was the forge's own hand.
		if d.Gate != nil && hash != "" {
			if rerr := d.Gate.RecordForgeAccept(in.Repo, hash); rerr != nil {
				log.Printf("gate: record accept %s: %v", l.ID, rerr)
			}
		}
	case "reject":
		state = "rejected"
	}
	d.notifySettled(ctx, in.Repo, l, verb)
	return landingSayOut{Hash: hash, State: state}, nil
}

// closeNamedTickets closes the tickets a landing's message said it
// would, and says so on each one.
//
// Best-effort by contract. The merge is already written and already
// durable; a tracker that will not take the update is a note that did
// not land, never a landing that did not. Failures are logged and the
// accept still succeeds — the opposite would make a settled record
// depend on a side effect nobody asked to be atomic.
//
// Only tickets in the SAME repository, and only ones that resolve. A
// cross-repository reference resolves against a store this caller may
// not even be able to read, and a landing here has no business
// settling work over there.
func (d Deps) closeNamedTickets(ctx context.Context, repoName string, repo *Repo, l index.LandingSummary, merge string) {
	if repo == nil {
		return
	}
	for _, t := range ticketsToClose(repoName, repo.Index, l) {
		note := "Closed by " + l.Branch + " landing on " + l.Target
		if merge != "" {
			note += " (" + shortHash(merge) + ")"
		}
		fields := map[string]string{
			"status": "Closed", "icomment": note, "mimetype": authoredMimetype,
		}
		if err := repo.write(func() error {
			_, werr := repo.Zit.TicketSet(t.ID, author(ctx), fields)
			return werr
		}); err != nil {
			log.Printf("landing %s: closing ticket %s: %v", shortHash(l.ID), shortHash(t.ID), err)
			continue
		}
		if err := repo.synced(); err != nil {
			log.Printf("landing %s: closing ticket %s: fold: %v", shortHash(l.ID), shortHash(t.ID), err)
		}
	}
}

// ticketsToClose picks the tickets a landing's message names and this
// forge may actually settle. Separate from the writing so the decision
// — which is where every mistake would be — can be tested without a
// store behind it.
func ticketsToClose(repoName string, ix *index.Index, l index.LandingSummary) []index.TicketState {
	if ix == nil {
		return nil
	}
	var out []index.TicketState
	for _, ref := range markup.ClosingRefs(l.Title) {
		// A cross-repository reference resolves against a store this
		// caller may not even be able to read, and a landing here has
		// no business settling work over there.
		if ref.Repo != "" && ref.Repo != repoName {
			continue
		}
		t, ok, err := ix.TicketByPrefixAll(ref.ID)
		if err != nil || !ok {
			continue
		}
		// Already closed stays closed, and stays closed by whoever
		// closed it: re-closing would overwrite a resolution somebody
		// chose with this landing's generic one.
		if strings.EqualFold(ticketField(t, "status"), "Closed") {
			continue
		}
		out = append(out, t)
	}
	return out
}

// ticketField reads one folded scalar off a ticket, empty when the
// tracker's vocabulary does not carry it.
func ticketField(t index.TicketState, name string) string {
	for _, f := range t.Fields {
		if strings.EqualFold(f.Name, name) {
			return f.Value
		}
	}
	return ""
}

// shortHash is the twelve characters a person reads a hash by.
func shortHash(s string) string {
	if len(s) > 12 {
		return s[:12]
	}
	return s
}

// settlement names what each settling verb is, in the vocabulary's
// words and in a person's. A verb absent from this map settles nothing
// — which is how `comment` is excluded, rather than by a second
// condition somewhere else that could drift away from this one.
var settlement = map[string]struct {
	kind evt.Kind
	said string
}{
	"accept": {evt.LandingAccepted, "accepted"},
	"reject": {evt.LandingRejected, "rejected"},
}

// notifyOpened tells the owners of the target branch that a landing
// onto it is waiting for them. Owners only: an unprotected branch has
// nobody whose decision the landing waits on, and the activity topic
// already carries the fact for everyone else.
func (d Deps) notifyOpened(ctx context.Context, repo, id, branch, target string) {
	actor := ops.User(ctx)
	_, owners := d.protectionOf(repo, target)
	told := map[string]bool{"": true, actor: true}
	for _, who := range owners {
		if told[who] {
			continue
		}
		told[who] = true
		d.fireNotify(NotifyEvent{
			Workflow: "quarry-landing", To: who, Kind: evt.LandingOpened,
			Payload: map[string]any{
				"repo": repo, "from": actor,
				"summary": actor + " proposed " + branch + " → " + target,
				"link":    d.link("/r/" + repo + "/landing/" + id),
			},
			TxnID: "landing:open:" + id + ":" + who,
		})
	}
}

// notifySettled tells the people a settled landing is ABOUT: whoever
// opened it, and whoever owns the branch it settled onto. Nobody else —
// a landing settling is not the repository talking, and the topic
// already carries it for those who want everything.
//
// Fired from the OP and never from the fold, though the fold raises
// landing.accepted for the event and webhook planes: a reindex replays
// every artifact, and a replay that re-notified would ping the whole
// repository for work settled months ago (RFC 0001 §5, RFC 0011 §2.3.4).
// The idempotency key is address-based for the same reason at a smaller
// scale — a retried op collapses onto the key it already used.
func (d Deps) notifySettled(ctx context.Context, repo string, l index.LandingSummary, verb string) {
	s, ok := settlement[verb]
	if !ok {
		return
	}
	actor := ops.User(ctx)
	_, owners := d.protectionOf(repo, l.Target)
	told := map[string]bool{"": true, actor: true}
	for _, who := range append([]string{l.Author}, owners...) {
		if told[who] {
			continue
		}
		told[who] = true
		d.fireNotify(NotifyEvent{
			Workflow: "quarry-landing", To: who, Kind: s.kind,
			Payload: map[string]any{
				"repo": repo, "from": actor,
				"summary": actor + " " + s.said + " " + l.Branch + " → " + l.Target,
				"link":    d.link("/r/" + repo + "/landing/" + l.ID),
			},
			TxnID: "landing:" + verb + ":" + l.ID + ":" + who,
		})
	}
}

// AutoReporter is the arm's own account of itself, for the page. Split
// from AutoSweeper because a forge may store arms without reporting on
// them, and the landing page must still render.
type AutoReporter interface {
	AutoStateOf(repo, landing string) (oltp.AutoState, bool)
}

// AutoSweeper is the sweep's view of the armed set.
type AutoSweeper interface {
	AutoAccepts() ([]oltp.AutoAccept, error)
	ClearAutoAccept(repo, landing string) error
	// SetAutoWaiting records what this try ran into, so the landing's
	// own page can say what an armed record is waiting on instead of
	// looking idle. Returns nothing: a note for a reader must not be
	// able to fail a sweep.
	SetAutoWaiting(repo, landing, reason string)
}

// AutoAcceptSweep tries every armed landing once and reports what
// landed. Each try is the ordinary landing.accept written by the
// arming account's hand, so protection and required checks gate at
// fire time exactly as a button press would.
//
// An arm is cleared in exactly two cases: this sweep wrote the merge,
// or the record is verifiably settled by another hand. Everything else
// — checks still red, a lost protection, a store that would not sync,
// an index that cannot find the record right now — leaves the arm in
// place, and says why in the log. An earlier version disarmed on "not
// found", which an index mid-rebuild answers for every record, so an
// arm could vanish without a trace and the landing wait forever.
func (d Deps) AutoAcceptSweep(store AutoSweeper) []string {
	rows, err := store.AutoAccepts()
	if err != nil {
		return nil
	}
	var landed []string
	for _, a := range rows {
		ctx := ops.WithUser(context.Background(), a.Account)
		out, err := d.landingSay(ctx, landingSayIn{Repo: a.Repo, ID: a.Landing}, "accept")
		switch {
		case err == nil:
			_ = store.ClearAutoAccept(a.Repo, a.Landing)
			// A hash means THIS sweep wrote the merge; none means the
			// record was already settled, which only needed disarming.
			if out.Hash != "" {
				landed = append(landed, a.Repo+"/"+a.Landing)
			}
		case strings.Contains(err.Error(), "required checks not green"):
			// The ordinary wait. Still recorded, because the PAGE has
			// somewhere to put it even though the log does not want a
			// line every thirty seconds.
			store.SetAutoWaiting(a.Repo, a.Landing, err.Error())
		default:
			store.SetAutoWaiting(a.Repo, a.Landing, err.Error())
			log.Printf("landing: auto-land %s/%.12s (armed by %s) is waiting: %v", a.Repo, a.Landing, a.Account, err)
		}
	}
	return landed
}

// 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.
// WarmMergeProbes asks zit whether every open landing still merges
// cleanly, for any pair of tips it has not asked about yet. The probe
// is a real merge dry-run, seconds on a large store, and it used to run
// the first time a READER opened the page after either tip moved —
// the 4-second landing pages on the ops board. Run from a ticker, it
// runs after the push instead, and the page finds the answer cached.
// Returns how many landings were looked at.
func (d Deps) WarmMergeProbes() int {
	if d.Repos == nil {
		return 0
	}
	n := 0
	for _, name := range d.Repos.Names() {
		repo, ok := d.Repos.Get(name)
		if !ok || repo.Index == nil || repo.Zit == nil {
			continue
		}
		open, err := repo.Index.Landings("open", 0)
		if err != nil {
			continue
		}
		for _, l := range open {
			branchTip, targetTip := branchTipOf(repo, l.Branch), branchTipOf(repo, l.Target)
			if branchTip == "" || targetTip == "" {
				continue
			}
			if _, _, err := repo.Zit.MergeProbe(branchTip, targetTip, branchTip+":"+targetTip); err != nil {
				log.Printf("landing: probe %s/%.12s: %v", name, l.ID, err)
				continue
			}
			n++
		}
	}
	return n
}

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"`
	// Total is how many events the caller's repositories hold in all.
	Total int `json:"total"`
	// Depth is how far back this feed pages — the lesser of Total and
	// the feed's bound. A landing page is a window on recent activity;
	// the archive is each repository's own timeline.
	Depth int `json:"depth"`
	// 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"`
	// Fields is every column this tracker's tickets carry, most-used
	// first, INCLUDING the ones with their own input. It is the schema
	// as far as anything outside the store can know it: there is no
	// verb that reports one, so what the tickets carry is the answer.
	// A form reads this to offer the columns fossil never shipped.
	Fields []index.TicketFieldUse `json:"fields,omitempty"`
}

// mergedTicketVocab is the repository's ticket vocabulary: the defaults
// with .quarry/tickets.json laid over them. source names which one
// answered; problem is non-empty when the file was present and unusable,
// which the config page shows rather than erroring on.
//
// One function because two readers must agree: the op that PUBLISHES the
// vocabulary and the check that REFUSES a value outside it. A second
// copy of this merge would let a forge advertise one list and enforce
// another, which is a worse failure than either being wrong.
func (d Deps) mergedTicketVocab(ctx context.Context, repo *Repo) (merged map[string][]string, source, problem string) {
	merged = map[string][]string{}
	for k, v := range DefaultTicketVocabulary {
		merged[k] = v
	}
	source = "default"
	version := "trunk"
	if repo != nil && 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 != nil && repo.Zit != nil {
		raw, err = repo.Zit.Cat(ctx, ticketConfigPath, version)
	}
	if err != nil {
		return merged, source, ""
	}
	over, perr := parseTicketVocab(raw)
	if perr != nil {
		return merged, source, perr.Error()
	}
	for k, v := range over {
		merged[k] = v
	}
	return merged, ticketConfigPath, ""
}

// refuseUnknownVocab rejects a field value the repository's vocabulary
// does not list.
//
// ticket.set stored whatever it was handed, so a typo became a status
// no filter offers and no reader expects -- a ticket that is Open to the
// tracker and "Bogus_Not_A_Status" to itself (7d46f782).
//
// An EMPTY list means the field is free text (subsystem is, by default),
// so it is not checked. A field the vocabulary does not mention at all
// is likewise none of this function's business. The refusal names the
// words that would have worked, because a caller who guessed one value
// wrong will otherwise guess again.
// orderNames lists the sort keys, from the same map the SQL comes from.
// Sorted so a refusal reads the same way twice.
func orderNames() []string {
	out := make([]string, 0, len(index.TicketOrders))
	for k := range index.TicketOrders {
		if k == "" {
			continue // the default, named by omitting the parameter
		}
		out = append(out, k)
	}
	slices.Sort(out)
	return out
}

// refuseUnknownFilter rejects a LISTING filter whose value cannot match
// anything, and says which words would have.
//
// The same vocabulary that gates a write gates the read, from one
// definition -- a read filter and a write check that each restated the
// vocabulary would drift, and nothing would compare them.
//
// But the vocabulary is not the whole answer here, and this is the part
// worth reading twice. The forge accepts an artifact carrying a status
// its vocabulary does not list: refusing history at ingest is a promise
// a DAG-backed forge does not make, and that was decided deliberately.
// So a value can be sitting in the tracker and absent from the
// vocabulary at the same time. Validating the filter against the
// vocabulary alone would refuse the one word that finds those tickets
// and make them unreachable -- a worse failure than the silent empty
// answer this replaces, because it would be a confident refusal of
// something real.
//
// Hence: the declared vocabulary OR anything genuinely present. What is
// left is a typo, and a typo is the whole reason to answer at all.
// archiveKey names one cached archive. A resolved check-in and a
// container are most of it, and the comment that used to sit on the
// cache call said they were all of it: "a pure function of a check-in
// and a container, so an entry is never stale."
//
// That premise was false, and it cost a real ticket its fix. The bytes
// are produced by zit, and zit's container format can change: 0.3.25
// started setting the UTF-8 flag on zip member names (zit#207, for
// 620e562f). The forge took that release and kept serving a flag-less
// zip for every check-in anyone had already downloaded, because the
// key did not know who had written the entry. The upstream fix was
// invisible until the cache happened to evict it.
//
// So the PRODUCER is part of the key. A new zit means a new entry; the
// old ones age out by the cache's own size rule rather than being
// hunted. ZitVersion is what `zit version` said at startup — empty on a
// forge that could not ask, which keys every archive under one producer
// exactly as before.
func archiveKey(repo, producer, version, format string) string {
	return repo + "/" + producer + "/" + version + "." + format
}

func (d Deps) refuseUnknownFilter(ctx context.Context, repo *Repo, filters map[string]string) error {
	vocab, _, _ := d.mergedTicketVocab(ctx, repo)
	for _, field := range []string{"status", "type", "severity", "priority"} {
		want := filters[field]
		if want == "" {
			continue
		}
		allowed := vocab[field]
		if len(allowed) == 0 {
			continue // a field with no declared words is free text
		}
		if slices.Contains(allowed, want) {
			continue
		}
		present, err := repo.Index.TicketFieldValues(field, seesPrivate(ctx))
		if err != nil {
			return err
		}
		if slices.Contains(present, want) {
			continue
		}
		// Present-but-undeclared values are named too. They are as
		// valid a thing to ask for as the vocabulary's own words, and
		// a refusal that hid them would be telling the caller their
		// tracker does not contain what it contains.
		words := append(append([]string{}, allowed...), extras(allowed, present)...)
		return fmt.Errorf("%s %q is not one of: %s: %w",
			field, want, strings.Join(words, ", "), ops.ErrInvalid)
	}
	return nil
}

// extras are the values a tracker holds that its vocabulary does not
// declare -- arrived over the wire, or left behind by a vocabulary that
// changed under existing tickets.
func extras(declared, present []string) []string {
	var out []string
	for _, p := range present {
		if !slices.Contains(declared, p) {
			out = append(out, p)
		}
	}
	slices.Sort(out)
	return out
}

func refuseUnknownVocab(vocab map[string][]string, fields map[string]string) error {
	for _, name := range vocabFieldOrder {
		want, ok := fields[name]
		if !ok || want == "" {
			continue
		}
		words := vocab[name]
		if len(words) == 0 {
			continue
		}
		if slices.Contains(words, want) {
			continue
		}
		return fmt.Errorf("%s %q is not one of: %s: %w",
			name, want, strings.Join(words, ", "), ops.ErrInvalid)
	}
	return nil
}

// 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"`
	// Fossil overrides the repository's tier for THIS ticket: write the
	// artifact log even where new tickets normally stay on the forge.
	// Takes the admin letter, because permanence is what it buys.
	Fossil    bool   `json:"fossil"`
	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"`
	// FoundIn is the tenth and last of fossil's default columns, and was
	// the one field of that schema quarry could read but never write.
	// private_contact is the other one it does not write, deliberately:
	// fossil withholds it, so quarry does not author it either.
	FoundIn string `json:"foundin" validate:"max=80"`
	// Fields carries every OTHER column this tracker defines — assignee,
	// closer, submitter, or whatever else the repository's schema
	// declares. Validated against the fields the tracker's own tickets
	// carry: a name it does not define is refused, because fossil
	// creates a column on demand and an unchecked map would make every
	// ticket write a schema migration.
	Fields map[string]string `json:"fields" validate:"max=24"`
	// Attach lists upload tickets begun without a target
	// (`attachment.begin` with a family) — files pasted or dropped into
	// the composer while this was written. Each is committed against
	// the new object once it exists; a refusal is reported in
	// `attachErrors` and never undoes the write.
	Attach []string `json:"attach" validate:"max=20"`
}

type ticketNewOut struct {
	ID   string `json:"id"`
	Hash string `json:"hash"`
	// AttachErrors names, one line each, the upload tickets in `attach`
	// that could not be committed. Absent when every file landed.
	AttachErrors []string `json:"attachErrors,omitempty"`
}

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"`
	// FoundIn is the tenth and last of fossil's default columns, and was
	// the one field of that schema quarry could read but never write.
	// private_contact is the other one it does not write, deliberately:
	// fossil withholds it, so quarry does not author it either.
	FoundIn string `json:"foundin" validate:"max=80"`
	// Fields carries every OTHER column this tracker defines — assignee,
	// closer, submitter, or whatever else the repository's schema
	// declares. Validated against the fields the tracker's own tickets
	// carry: a name it does not define is refused, because fossil
	// creates a column on demand and an unchecked map would make every
	// ticket write a schema migration.
	Fields map[string]string `json:"fields" validate:"max=24"`
	// Attach lists upload tickets begun without a target
	// (`attachment.begin` with a family) — files pasted or dropped into
	// the composer while this was written. Each is committed against
	// the new object once it exists; a refusal is reported in
	// `attachErrors` and never undoes the write.
	Attach []string `json:"attach" validate:"max=20"`
}

type ticketSetOut struct {
	Hash string `json:"hash"`
	// Local marks a ticket the forge holds rather than the DAG. Its
	// Hash is empty because no artifact was written -- there is none
	// to name. Said out loud because an empty hash with no
	// explanation reads as a write that failed, which is how it was
	// reported (3a72a925).
	Local bool `json:"local,omitempty"`
	// AttachErrors names, one line each, the upload tickets in `attach`
	// that could not be committed. Absent when every file landed.
	AttachErrors []string `json:"attachErrors,omitempty"`
}

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

// exceptionClearIn names one signature to discard. The signature, not
// the message: a message carries the latest occurrence's specifics and
// two occurrences of the same fault rarely have identical text, which
// is exactly why the journal groups by a hash of it.
type exceptionClearIn struct {
	Sig string `json:"sig"`
}

// exceptionClearOut reports whether the journal was holding it. False
// is a normal answer, not a failure: two admins on the same panel, or
// one double-click, both end with the signature gone.
type exceptionClearOut struct {
	Cleared bool `json:"cleared"`
}

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"`
	// Parent is set on a companion store (RFC 0015): the repository
	// whose forum this is, so a stale companion reads as its parent's
	// forum being behind rather than as an orphan.
	Parent   string     `json:"parent,omitempty"`
	Code     string     `json:"code"`
	Checkins int        `json:"checkins"`
	Indexing bool       `json:"indexing"`
	Health   RepoHealth `json:"health"`
	// Bucket names the bound attachments bucket (RFC 0016), with its
	// last probe beside it the way a mirror's last pull is: an
	// operator scanning Fleet sees a bucket that stopped answering
	// without opening the repository's settings.
	Bucket         string `json:"bucket,omitempty"`
	BucketProbedAt string `json:"bucketProbedAt,omitempty"`
	BucketErr      string `json:"bucketErr,omitempty"`
}

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"`
	// ErrorDistinct is how many signatures EXIST, against the Errors
	// slice's length, which is how many were returned. The panel used
	// to print the slice's length as the number of signatures, so a
	// store with thirty faults reported twenty-five and hid five with
	// no sign it had.
	ErrorDistinct int `json:"errorDistinct"`
}

// fleetSyncIn names one repository, or all of them when Repo is empty.
// fleetPhase reads what every store is doing and names the moment.
//
// Derived rather than tracked: a phase field somewhere would be one
// more thing to keep true, and everything needed is already reported
// per store. The order of the tests is the priority a reader wants —
// something rebuilding outranks something syncing, and a store that has
// never finished a round outranks both.
func fleetPhase(d Deps) opsPhase {
	p := opsPhase{Name: "rest"}
	if d.Repos == nil {
		return p
	}
	names := d.Repos.Names()
	p.Repos = len(names)
	moving := false
	for _, name := range names {
		repo, ok := d.Repos.Get(name)
		if !ok || repo == nil {
			continue
		}
		if repo.Indexing != nil && repo.Indexing() {
			p.Working = append(p.Working, name)
		}
		if repo.Health == nil {
			continue
		}
		h := repo.Health()
		if h.LastOK != "" {
			p.Ready++
		}
		if h.Stalled {
			p.Stalled = append(p.Stalled, name)
		}
		p.Artifacts += h.Total
		if h.LastCount > 0 {
			moving = true
		}
		if h.RoundSeconds > p.SlowestRoundSeconds {
			p.SlowestRoundSeconds = h.RoundSeconds
		}
	}
	sort.Strings(p.Working)
	sort.Strings(p.Stalled)
	switch {
	case len(p.Working) > 0:
		p.Name = "indexing"
	// A store that has never completed a round is still coming up. A
	// STALLED store never will without help, so it does not hold the
	// fleet in "starting" forever — it is reported as stalled instead.
	case p.Ready+len(p.Stalled) < p.Repos:
		p.Name = "starting"
	case moving:
		p.Name = "syncing"
	}
	return p
}

// ms is a duration in whole milliseconds. The wire carries a number a
// caller can sort and threshold, not a Go duration string.
func ms(d time.Duration) int64 { return d.Milliseconds() }

type opsRouteIn struct {
	Path  string `json:"path" validate:"required,max=200"`
	Limit int    `json:"limit" validate:"min=0,max=100,default=20"`
	// Days is the history window. Two weeks by default, which is the
	// default retention: asking for more than is kept would draw a
	// trend that thins out for a reason nothing on the page explains.
	Days int `json:"days" validate:"min=0,max=90,default=14"`
}

// opsRouteRender is one request and what it asked the store for.
type opsRouteRender struct {
	At string `json:"at"`
	// URL is the address actually requested, which is the one thing the
	// route pattern cannot say. A drilldown listing a dozen renders of
	// GET /r/{repo}/ticket/{id} could name the repository and never
	// which ticket — so a reader who found a 3-second render had no way
	// to go and look at it. obs has carried this all along for exactly
	// this reason; the op dropped it.
	URL       string         `json:"url,omitempty"`
	Repo      string         `json:"repo,omitempty"`
	ElapsedMs int64          `json:"elapsedMs"`
	OpsMs     int64          `json:"opsMs"`
	ZitMs     int64          `json:"zitMs"`
	Status    int            `json:"status"`
	Calls     []opsRouteCall `json:"calls,omitempty"`
	// CallMs is what the listed calls cost together — the part of the
	// request the store is answerable for, said once so a reader does
	// not add a column in their head.
	CallMs int64 `json:"callMs"`
}

type opsRouteCall struct {
	Verb       string `json:"verb"`
	Repo       string `json:"repo,omitempty"`
	DurationMs int64  `json:"durationMs"`
}

type opsRouteOut struct {
	Path    string           `json:"path"`
	Renders []opsRouteRender `json:"renders"`
	// ByRepo is the same renders grouped, because a route pattern hides
	// the one variable that matters most on this fleet: GET /r/{repo}
	// is a different page on pikchr than on tcl, and the panel above
	// rolls both into one line.
	ByRepo []opsRouteRepo `json:"byRepo"`
	// Trend is this route's daily percentiles from the durable store,
	// oldest first. Empty when history is off — the rings hold minutes
	// and a trend drawn from them would read as days.
	Trend []obs.TrendPoint `json:"trend"`
	// TrendDays is the window asked for, so the page can say what an
	// empty trend means: no history rather than no traffic.
	TrendDays int `json:"trendDays"`
}

type opsRouteRepo struct {
	Repo  string `json:"repo"`
	Count int    `json:"count"`
	P50Ms int64  `json:"p50Ms"`
	MaxMs int64  `json:"maxMs"`
}

// sortOpsPaths orders the route table. p95 is the default because the
// panel exists to find the tail; the others are here because "which
// route is busiest" and "where does the worst single request live" are
// different questions from the same table.
func sortOpsPaths(ps []opsPath, by string) {
	less := func(i, j int) bool { return ps[i].P95Ms > ps[j].P95Ms }
	switch by {
	case "p50":
		less = func(i, j int) bool { return ps[i].P50Ms > ps[j].P50Ms }
	case "max":
		less = func(i, j int) bool { return ps[i].MaxMs > ps[j].MaxMs }
	case "n":
		less = func(i, j int) bool { return ps[i].Count > ps[j].Count }
	case "path":
		less = func(i, j int) bool { return ps[i].Path < ps[j].Path }
	}
	sort.SliceStable(ps, func(i, j int) bool {
		if less(i, j) {
			return true
		}
		if less(j, i) {
			return false
		}
		// Ties break on the name so the table does not reshuffle
		// under a reader between two three-second refreshes.
		return ps[i].Path < ps[j].Path
	})
}

type opsHistoryIn struct {
	Find  string `json:"find" validate:"max=200"`
	Repo  string `json:"repo" validate:"max=64"`
	Sort  string `json:"sort" validate:"oneof=recent|slowest"`
	Days  int    `json:"days" validate:"min=0,max=90,default=14"`
	Limit int    `json:"limit" validate:"min=0,max=500,default=100"`
}

type opsHistoryOut struct {
	Rows []obs.HistoryRow `json:"rows"`
	// The query echoed back, so the page can render its own controls
	// from the answer rather than from what it thinks it asked.
	Find  string     `json:"find"`
	Repo  string     `json:"repo"`
	Sort  string     `json:"sort"`
	Days  int        `json:"days"`
	Store obs.Health `json:"store"`
}

type opsStatusIn struct {
	Limit int `json:"limit" validate:"min=0,max=200,default=25"`
	// Find narrows the route table to matching paths, case-insensitively.
	// A fleet serving two dozen repositories has a routes panel long
	// enough that finding one is a scroll, and the answer to "is the
	// wiki slow" should not be reading.
	Find string `json:"find" validate:"max=200"`
	// Sort orders the route table: by p95 (the default and the reason
	// the panel exists), p50, max, count, or path.
	Sort string `json:"sort" validate:"oneof=p95|p50|max|n|path"`
}

type opsPath struct {
	Path  string `json:"path"`
	Count int    `json:"count"`
	P50Ms int64  `json:"p50Ms"`
	P95Ms int64  `json:"p95Ms"`
	MaxMs int64  `json:"maxMs"`
}

type opsRender struct {
	At   string `json:"at"`
	Path string `json:"path"`
	// URL is what was actually requested. The route groups these rows;
	// this is what tells one from another.
	URL       string `json:"url,omitempty"`
	Repo      string `json:"repo,omitempty"`
	ElapsedMs int64  `json:"elapsedMs"`
	// OpsMs CONTAINS ZitMs. They are container and content, never a sum.
	OpsMs  int64 `json:"opsMs"`
	ZitMs  int64 `json:"zitMs"`
	Status int   `json:"status"`
}

type opsVerb struct {
	Verb    string `json:"verb"`
	Count   int    `json:"count"`
	TotalMs int64  `json:"totalMs"`
	MaxMs   int64  `json:"maxMs"`
	// Repos and Causes are where these calls came from: which stores
	// they ran against, and what asked — a background activity by name
	// (warm, sync, ingest) or the route of the render that wanted it.
	// A verb and a duration say a listing was slow; these say whose.
	Repos  []opsTally `json:"repos,omitempty"`
	Causes []opsTally `json:"causes,omitempty"`
}

type opsTally struct {
	Name  string `json:"name"`
	Count int    `json:"count"`
}

// opsPhase is what the forge is DOING, as distinct from how fast it is
// doing it. The panels below it are steady-state numbers and say
// nothing while a fleet is coming up — which is the window an operator
// most wants to watch and the one where a forge most looks hung. A
// multi-repo re-ingest was mistaken for a hang on this very fleet,
// because nothing said "23 of 25 stores ready" while it worked.
type opsPhase struct {
	// Name is starting | indexing | syncing | rest.
	Name string `json:"name"`
	// Repos is every served store; Ready is those that have completed a
	// round. The pair is the progress bar: a forge is not up because it
	// answered the port, it is up when its stores are readable.
	Repos int `json:"repos"`
	Ready int `json:"ready"`
	// Working names the stores rebuilding right now, so the phase says
	// WHICH rather than merely that something is happening.
	Working []string `json:"working,omitempty"`
	// Stalled names stores that stopped retrying. They are not slow;
	// they are waiting for a person, and a progress bar that counted
	// them as pending would wait forever.
	Stalled []string `json:"stalled,omitempty"`
	// Artifacts is what this process has ingested since it started —
	// the number that moves during a batch sync and is flat at rest.
	Artifacts int `json:"artifacts"`
	// SlowestRoundSeconds is the worst recent round across the fleet,
	// which is what makes "syncing" legible as fast or struggling.
	SlowestRoundSeconds float64 `json:"slowestRoundSeconds"`
}

type opsStatusOut struct {
	Phase   opsPhase    `json:"phase"`
	Paths   []opsPath   `json:"paths"`
	Renders []opsRender `json:"renders"`
	Verbs   []opsVerb   `json:"verbs"`
	// LiveSubprocess is how many store subprocesses are running RIGHT
	// NOW; Peak is the most this process has ever had at once. Peak
	// outlives the incident, which is the point — a page that reads
	// zero after the fire is out teaches an operator nothing.
	LiveSubprocess int `json:"liveSubprocess"`
	PeakSubprocess int `json:"peakSubprocess"`
	// SubprocessSeries and RenderSeries are the shape behind those two
	// numbers over the recent window: how many store calls were RUNNING
	// at each sample, and how many renders began in each bin. Both are
	// read off the rings the panels below already use, so they cost a
	// pass over memory and nothing else — no sampler, no second store.
	SubprocessSeries []int `json:"subprocessSeries,omitempty"`
	RenderSeries     []int `json:"renderSeries,omitempty"`
	// SeriesWindowSeconds is what those two series span, so the page can
	// say the period out loud rather than leaving a line with no scale.
	SeriesWindowSeconds int `json:"seriesWindowSeconds,omitempty"`
	// Lanes are the admission bounds and what they did: how many ran,
	// how many were told busy, how long callers waited.
	Lanes      []zitcli.Lane `json:"lanes"`
	ErrorTotal int           `json:"errorTotal"`
	// BudgetMs is the render budget the panel colours against, sent
	// rather than hardcoded in the template: it was a literal in one
	// place and a constant in another, so a forge could show a route in
	// red while the notification threshold disagreed.
	BudgetMs int64 `json:"budgetMs"`
	// Store is the durable analytics store's state. The live panels
	// read the in-memory rings, so a forge whose store failed to open
	// looks exactly like a healthy one until something says otherwise.
	Store obs.Health `json:"store"`
}

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 {
	// HasCI: this repository has at least one CI run, so the navigation
	// has a reason to offer the tab.
	HasCI bool `json:"hasCI"`
	// CIDeclinedBy: the admin who said this repository does not use CI.
	// Empty is "nobody has answered", which is the state the tab's offer
	// exists for — not the same as "no CI".
	CIDeclinedBy string `json:"ciDeclinedBy,omitempty"`
	// 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"`
	OpenLandings int  `json:"openLandings"`
	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"`
}

// repoActivityIn selects the window and the population for the core
// sample. An empty Since hands the choice to the server (RFC 0013 §4);
// an empty Branch or User means all of them.
type repoActivityIn struct {
	Repo   string `json:"repo" validate:"required,max=64"`
	Branch string `json:"branch" validate:"max=64"`
	// User is set by the person page; the activity page has no person
	// control, because the person page IS the person filter.
	User  string `json:"user" validate:"max=64"`
	Since string `json:"since" validate:"max=10"`
	Until string `json:"until" validate:"max=10"`
}

// activityAuthor is one person and the colour every renderer must draw
// them in. Checkins counts check-ins including merges; Files counts
// only authored paths, which is the whole point of RFC 0013 §3.
type activityAuthor struct {
	Name     string `json:"name"`
	Checkins int    `json:"checkins"`
	Files    int    `json:"files"`
	Hue      int    `json:"hue"`
	Chroma   int    `json:"chroma"`
	Shade    int    `json:"shade"`
}

// activityRow is one check-in. Day is days since Since; Hour is the
// check-in's own UTC hour. Author indexes Authors; Files is the
// authored file count, so a pure merge reports zero.
type activityRow struct {
	Day    int    `json:"day"`
	Hour   int    `json:"hour"`
	Author int    `json:"author"`
	Merge  int    `json:"merge"`
	Files  int    `json:"files"`
	Hash   string `json:"hash"`
	Branch string `json:"branch"`
}

// activityTouch is one (check-in, leaf path) pair. Path indexes Paths.
// Inherited marks a touch a merge carried, so a client that wants only
// authored work drops them with one filter.
type activityTouch struct {
	Day       int `json:"day"`
	Path      int `json:"path"`
	Author    int `json:"author"`
	Inherited int `json:"inherited"`
}

type repoActivityOut struct {
	Since   string           `json:"since"`
	Until   string           `json:"until"`
	Authors []activityAuthor `json:"authors"`
	// Paths is tree-axis order — by directory, then lowercase path — so
	// both renderers band a directory's files together identically. A
	// path touched only by merges or by sweeps (40+ paths at once) in
	// the window is folded into its directory's row, named "dir/", which
	// sorts first in the directory.
	Paths   []string        `json:"paths"`
	Rows    []activityRow   `json:"rows"`
	Touches []activityTouch `json:"touches"`
	// Window is "given" when the caller named Since, "picked" when the
	// server chose it. A chart that silently retargets its own window
	// is a chart nobody can cite.
	Window string `json:"window"`
}

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

// validateActivityWindow rejects a malformed or inverted Since/Until
// pair before it reaches the index. pickWindow makes the same date-shape
// check on its own — this one exists so a bad request gets back
// ops.ErrInvalid (a 400) instead of a bare parse error.
func validateActivityWindow(since, until string) error {
	if since != "" {
		if _, err := time.Parse("2006-01-02", since); err != nil {
			return fmt.Errorf("since must be YYYY-MM-DD: %w", ops.ErrInvalid)
		}
	}
	if until != "" {
		if _, err := time.Parse("2006-01-02", until); err != nil {
			return fmt.Errorf("until must be YYYY-MM-DD: %w", ops.ErrInvalid)
		}
	}
	if since != "" && until != "" && since > until {
		return fmt.Errorf("since %q is after until %q: %w", since, until, ops.ErrInvalid)
	}
	return nil
}

// validateActivityBranch rejects a Branch that names no branch in the
// index, so a typo gets ops.ErrInvalid instead of a silently empty
// chart.
func validateActivityBranch(ix *index.Index, branch string) error {
	if branch == "" {
		return nil
	}
	ok, err := ix.HasBranch(branch)
	if err != nil {
		return err
	}
	if !ok {
		return fmt.Errorf("unknown branch %q: %w", branch, ops.ErrInvalid)
	}
	return nil
}

// activityDay is one day of the core slice. Top is every author of the
// day as an [author, n] pair, ranked by check-ins; Path indexes Paths
// and is -1 on a day whose only file touches came in through a merge.
type activityDay struct {
	N      int `json:"n"`
	Merges int `json:"merges"`
	// Top is [][author, n] rather than [][2]int because the op schema
	// describes slices, not fixed-length arrays; the JSON is identical.
	Top     [][]int `json:"top"`
	Authors int     `json:"authors"`
	Path    int     `json:"path"`
}

type repoActivityDaysOut struct {
	First   string           `json:"first"`
	Last    string           `json:"last"`
	Authors []activityAuthor `json:"authors"`
	// Days is keyed by ISO date; a date absent from the map had no
	// check-ins, which is the fact the slice's gaps show.
	Days map[string]activityDay `json:"days"`
	// Paths is the top-level directories activityDay.Path indexes,
	// named once rather than repeated across thousands of cells.
	Paths    []string          `json:"paths"`
	Releases map[string]string `json:"releases"`
}

type accountListIn struct{}

// accountExplainIn names one account on one repository.
type accountExplainIn struct {
	Name string `json:"name" validate:"required,max=64"`
	Repo string `json:"repo" validate:"required,max=64"`
}

// accountExplainOut is a capability string read back as people read
// it (RFC 0008): the role it is, or is nearest to, the abilities that
// differ, the sentence of what the person can do here, and every
// letter of the effective set accounted for.
type accountExplainOut struct {
	Name      string   `json:"name"`
	Repo      string   `json:"repo"`
	Caps      string   `json:"caps"`      // the stored string; "" for none
	Effective string   `json:"effective"` // with the categories applied
	Role      string   `json:"role"`
	Exact     bool     `json:"exact"`
	Plus      []string `json:"plus"`
	Minus     []string `json:"minus"`
	Can       string   `json:"can"`
	Grants    []string `json:"grants"`
}

// CapsExplainer is the optional seam account.explain needs: the stored
// and effective strings for one account on one repository. An
// AccountStore without it has no explain op.
type CapsExplainer interface {
	CapsAt(account, repo string) (stored, effective string, err error)
}

// abilitySentences renders a role difference the way both account.explain
// and me.account report it — as what a person may do, not as letters. One
// helper rather than two loops, because the two ops describe the same
// account and a reader comparing them should not find the same fact
// worded two ways.
func abilitySentences(as []oltp.Ability) []string {
	out := make([]string, 0, len(as))
	for _, a := range as {
		out = append(out, a.Sentence)
	}
	return out
}

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

// AccountAuth is the self-service credential seam: verifying an
// account's OWN current password, and replacing it while sparing the
// session that asked. Optional — a store that does not implement it
// offers no self-service password change (me.passwd answers
// unavailable), the same shape AccountEmail uses for a forge with no
// email surface.
//
// Neither method returns anything that could verify a password itself
// (AccountStore's own rule, above): VerifyPassword takes a password IN
// and answers a bool, and SetPasswordSelf takes one in and answers only
// an error.
type AccountAuth interface {
	// VerifyPassword reports whether password is name's current one.
	VerifyPassword(name, password string) bool
	// SetPasswordSelf replaces name's password, then ends every OTHER
	// live session of that account — keep is that session's own audit
	// id (session.list's ID), or "" when there is none to spare (an
	// API caller with no browser cookie).
	SetPasswordSelf(name, password, keep string) error
}

func (d Deps) accountAuth() (AccountAuth, error) {
	if d.Accounts == nil {
		return nil, errNoAccounts()
	}
	aa, ok := d.Accounts.(AccountAuth)
	if !ok {
		return nil, fmt.Errorf("this forge's account store has no self-service password change: %w", ops.ErrUnavailable)
	}
	return aa, nil
}

type mePasswdIn struct {
	Current string `json:"current" validate:"required,max=1024"`
	New     string `json:"new" validate:"required,min=8,max=200"`
	// Keep names the session id (session.list's ID) the request itself
	// is running on, so ending every other session does not sign out
	// the browser making the change. "" when there is none to spare.
	Keep string `json:"keep" validate:"max=64"`
}

type meAccountIn struct{}

// meRepo is one repository this account may reach, and what it may do
// there. The role name rather than the letters: "maintainer" is a thing
// a person can act on and "gjorzh2mnc" is not.
type meRepo struct {
	Repo string `json:"repo"`
	// Role is the NEAREST preset name, which is a reading and not the
	// whole truth. Exact says whether the name IS the string; Plus and
	// Minus carry what the name does not.
	//
	// These three exist because Role alone understated an account: jan
	// held "Reader plus check in" on a repository, /me reported "reader",
	// and a client that read only this row concluded the account could
	// not push. It could — jva pushed with it. accounts.explain had the
	// right answer the whole time (exact:false, plus:["check in and
	// push"]), so the two ops disagreed about the same account, which is
	// worse than either being wrong alone: one of them is the one a
	// client happens to read.
	//
	// The same shape as account.explainOut's, deliberately: two ops
	// describing one account's reach must not need a translation table.
	Role  string   `json:"role"`
	Exact bool     `json:"exact"`
	Plus  []string `json:"plus"`
	Minus []string `json:"minus"`
}

type meAccountOut struct {
	Name    string `json:"name"`
	Admin   bool   `json:"admin"`
	Created string `json:"created"`
	// Repos and Teams are what this account may REACH. They were the
	// missing half of "nothing tells an account what it may do or what
	// it administers" (ticket f44e0c66): this op answered name, admin
	// and created, so a signed-in person asking the forge about
	// themselves learned only that they were not a site admin.
	//
	// Repository order is by name, so two reads of an unchanged account
	// agree — a map's order does not, and a caller diffing this would
	// see churn that is not there.
	Repos []meRepo `json:"repos"`
	Teams []string `json:"teams"`
	// PasswordAuth says the account store supports me.passwd — a page
	// draws the password section only when it does, matching the push
	// control's rule: a control that cannot work is not drawn as one.
	PasswordAuth bool `json:"passwordAuth"`
}

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|reader|developer|maintainer"`
}

type accountGrantOut struct {
	Name string `json:"name"`
	Repo string `json:"repo"`
	Role string `json:"role"`
	// Warning is the RFC 0008 guard speaking to whoever made the change
	// (the repository's own admin, ordinarily): the change happened,
	// and it left the repository without an admin.
	Warning string `json:"warning,omitempty"`
}

// GrantGuard is the optional seam the grant and revoke ops consult
// before writing (audit follow-up, ticket ed5bdd89c39a). The actor is
// warned, never refused — the letters are theirs to give and take.
type GrantGuard interface {
	GuardGrant(actor, account, repo, role string) (string, error)
	GuardRevoke(actor, account, repo string) (string, error)
}

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

type repoImportsIn struct{}

type repoImportsOut struct {
	Imports []RepoImport `json:"imports"`
	Count   int          `json:"count"`
}

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.
// repoUpdateIn is a PATCH, not a PUT: a field left out of the body is
// left alone.
//
// Every field was a plain string or bool, so "absent" and "the zero
// value" were the same thing and every call wrote every field. A
// description-only update therefore blanked the homepage, dropped the
// topics, made the repository public and turned MIRRORABLE off — and
// mirrorable off means the sync endpoint answers 404, so editing a
// description silently stopped the repository syncing. jva hit exactly
// that within a minute of the caps gate being fixed: /r/<repo>/xfer
// answered 404 to the next autosync, "Round-trips 0, Autosync failed"
// (dc748de1).
//
// The pointers are the same tri-state the team ops already use for the
// same reason, and that comment is worth reading: an upsert that treats
// an unmentioned field as false demotes people who were never
// mentioned.
//
// NOTE, because it is a trap: ops.Validate's applyRules falls through
// to `default: return nil` for a Pointer, so a `max=` tag on one of
// these is silently NOT enforced. The lengths are therefore checked by
// hand in Run. Moving a field to a pointer without doing that drops its
// validation and nothing says so.
type repoUpdateIn struct {
	Name        string  `json:"name" validate:"required,max=64"`
	Description *string `json:"description"`
	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"`
	Topics   *string `json:"topics"`
	// Chat: whether the repository has a chat channel.
	Chat *bool `json:"chat"`
	// Mirrorable: whether other people may keep a copy that follows
	// this repository. Admin-only to change, like every other field
	// here; a private repository ignores it in practice (RFC: a
	// private repository is never mirrorable), enforced at the sync
	// gate rather than refused here, so the choice survives a later
	// trip back to public.
	Mirrorable *bool `json:"mirrorable"`
}

// repoSettings is a repository's settings as they stand, so a partial
// update can write back the fields it was not asked to change.
//
// Every source is optional: a node without one of these Deps answers
// the zero value, which is the same answer repo.update gave for EVERY
// field before it learned to leave things alone. That is not ideal —
// it means a deployment missing RepoMeta still blanks the About fields
// on a partial update — but it is strictly better than blanking them
// on every deployment, and the fix belongs with whatever leaves those
// Deps unset rather than here.
type repoSettingsNow struct {
	description string
	private     bool
	homepage    string
	topics      string
	chat        bool
	mirrorable  bool
}

func (d Deps) repoSettings(repo string) repoSettingsNow {
	cur := repoSettingsNow{mirrorable: true, chat: true}
	if d.Descriptions != nil {
		cur.description = d.Descriptions()[repo]
	}
	if d.Private != nil {
		cur.private = d.Private()[repo]
	}
	if d.RepoMeta != nil {
		if meta, ok := d.RepoMeta()[repo]; ok {
			cur.homepage = meta.Homepage
			cur.topics = strings.Join(meta.Topics, " ")
			cur.chat = meta.Chat
			// The STORED flag. Deps.Mirrorable answers the effective
			// question and must not be written back — see RepoMeta.
			cur.mirrorable = meta.Mirrorable
		}
	}
	return cur
}

type repoLogIn struct {
	Repo  string `json:"repo" validate:"required,max=64"`
	Limit int    `json:"limit"`
}

type repoLogOut struct {
	Repo  string `json:"repo"`
	Count int    `json:"count"`
	// Changes is empty when no field has changed, which is NOT the same
	// as nobody having saved: a save that changes nothing records
	// nothing. The event stream is where an attempt is still visible.
	Changes []settingsChangeOut `json:"changes"`
}

// settingsChangeOut is one field that changed.
type settingsChangeOut struct {
	At    string `json:"at"`
	Actor string `json:"actor"`
	Field string `json:"field"`
	Was   string `json:"was"`
	Now   string `json:"now"`
}

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

// 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"`
	// Tag narrows to check-ins carrying this symbolic tag, as fossil's
	// /timeline?t= does. Written without the sym- prefix.
	Tag string `json:"tag" validate:"max=64"`
	// User narrows to one person's events, as fossil's /timeline?u=
	// does.
	User string `json:"user" validate:"max=64"`
	Y    string `json:"y" validate:"oneof=all|ci|w|t|e|f|l"`
}

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"`
	// Attachments are the files live on this page, targeted by page NAME
	// as fossil's A card spells a wiki target (RFC 0016 §Reading).
	Attachments []index.Attachment `json:"attachments"`
}

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"`
	Rev   string `json:"rev" validate:"max=64"` // artifact hash prefix; empty = latest
}

type technoteGetOut struct {
	Event   string `json:"event"`
	Hash    string `json:"hash"`
	Date    string `json:"date"`   // E card: the moment the note is filed under
	Edited  string `json:"edited"` // D card: when THIS version was written
	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"`
	// Revisions is how many edits the note has, so a page can offer its
	// history without a second call. Historical says this is not the
	// current one — the wiki page's banner, same job.
	Revisions  int  `json:"revisions"`
	Historical bool `json:"historical,omitempty"`
	// Attachments are the files live on this note, targeted by its event
	// id — the id is stable across edits, so the files are the note's and
	// not one revision's.
	Attachments []index.Attachment `json:"attachments"`
}

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

type technoteHistoryOut struct {
	Event   string              `json:"event"`
	Comment string              `json:"comment"`
	Edits   []index.TechnoteRev `json:"edits"`
	Count   int                 `json:"count"`
}

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"`
	// Limit is the page size. The default is a page a reader actually
	// reads; the cap is what stops a client asking for a forum's whole
	// history as one document, which on fossil's forum was 26MB of
	// markup for fifty rows' worth of attention.
	Limit int `json:"limit" validate:"min=0,max=200,default=50"`
	// Before is the keyset cursor from the previous page's next. It
	// serves the default recency sort, where the listing's order is the
	// index's own; the hot sort ranks in the forge and pages by Offset.
	Before string `json:"before" validate:"max=96"`
	Offset int    `json:"offset" validate:"min=0,max=100000"`
}

// 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.
	Spark []int `json:"spark,omitempty"`
	// Read marks a thread this caller has already seen with nothing new
	// since — the list mutes those. It is per-caller and false for
	// anonymous callers, who have no "already seen" to speak of.
	Read bool `json:"read"`
}

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

// forumHeldOut is one of the viewer's own submissions, still waiting on
// a moderator. It carries the state rather than only the fact, so the
// page can say what is happening rather than that something is.
type forumHeldOut struct {
	ID    int64  `json:"id"`
	Kind  string `json:"kind"`
	Root  string `json:"root"`
	Title string `json:"title"`
	At    string `json:"at"`
	State string `json:"state"`
	// Reason is the moderator's, on a rejected row. Body is the
	// author's own text, returned so a rejection hands their writing
	// back instead of swallowing it — both are theirs already.
	Reason string `json:"reason,omitempty"`
	Body   string `json:"body,omitempty"`
}

// forumModeration is the answer to the two questions a would-be poster
// has: is this forum moderated, and would it hold MY post.
//
// They differ because the gate exempts the moderation letters, so a
// moderator reading this sees a moderated forum that will not hold
// them. Conflating the two would tell a moderator their posts are
// staged when they never are.
//
// A signed-out reader is not a poster: claiming their posts would be
// held asserts something about an account that does not exist.
//
// `who` is the SIGNED-IN account, empty when there is none — callers pass
// ops.User(ctx) rather than author(ctx). author() substitutes the name
// "anonymous" for the absence of an account, which is right for
// attribution and wrong for this question: a guard written as `who == ""`
// never fired, and a signed-out reader was told their posts would be held.
// The sentinel is a NAME; whether there is an account is a different fact,
// and only this one has an empty spelling.
type forumModeration struct{ moderated, yours bool }

func forumModerationFor(d Deps, repo, who string) forumModeration {
	if d.Forum == nil || !d.Forum.ModerationEnabled(repo) {
		return forumModeration{}
	}
	if who == "" {
		return forumModeration{moderated: true}
	}
	return forumModeration{
		moderated: true,
		yours:     d.TrustedAuthor == nil || !d.TrustedAuthor(who, repo),
	}
}

type forumListOut struct {
	Threads []forumThreadOut `json:"threads"`
	// Held is the VIEWER'S OWN submissions waiting on a moderator.
	// Nobody else's: a queue read takes the moderation letter, and
	// this is not that. Empty for a signed-out reader and for anyone
	// with nothing held.
	//
	// It exists because a staged submission lives outside the DAG and
	// every other forum surface reads the DAG, so its own author
	// could not see it -- and concluded the post had failed. The
	// author seeing LESS than a stranger is the defect (G-118/G-119).
	Held []forumHeldOut `json:"held,omitempty"`
	// Moderated says this forum holds submissions for review, and
	// YourPostsHeld says it would hold THIS caller's.
	//
	// The two differ because the gate exempts anybody holding the
	// moderation letters, so "the forum is moderated" and "my post will
	// be staged" are different questions and the second is the one a
	// poster is actually asking.
	//
	// Neither was answerable before. The state was readable through
	// forum-queue, which needs the moderation letter and answers 403 to
	// exactly the person it holds, and through the public modlog, which
	// is empty on a repository that has never toggled — so reading it
	// meant assuming the default. A poster learned the rule from
	// staged:true in the write response, after posting (53589c4a64d3).
	//
	// Publishing the state discloses nothing: the modlog already
	// carries every enable and disable anonymously, with actor and
	// timestamp. Checked rather than assumed, because I asserted that
	// test about branch owners earlier today and it held for only half
	// of what I used it to justify.
	Moderated     bool `json:"moderated,omitempty"`
	YourPostsHeld bool `json:"yourPostsHeld,omitempty"`
	// 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 is how many threads this page holds; Total is how many the
	// forum has. They were the same number while the listing was the
	// whole forum, and a pager cannot be written from the first alone.
	Count int `json:"count"`
	Total int `json:"total"`
	// Next carries the reader to the following page: a cursor for the
	// recency sort, an offset for hot. Empty means this is the end.
	Next string `json:"next,omitempty"`
}

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"`
	// Edits is how many earlier versions stand behind this post and
	// Edited when the newest was written; zero for an unedited post.
	Edits  int    `json:"edits,omitempty"`
	Edited string `json:"edited,omitempty"`
	// 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"`
	// Attachments are the files live on THIS post, targeted by the post's
	// own hash. Per post and not per thread: a file belongs to the
	// message somebody attached it to.
	Attachments []index.Attachment `json:"attachments,omitempty"`
}

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"`
	// ChatRefs is the WINDOW-BOUNDED count of times chat named this
	// artifact (RFC 0011 §14). Nil when nothing has, or when the forge
	// keeps no chat refs. Deliberately not folded into Mentions: those
	// are durable index facts and these expire with the channel, and a
	// reader who cannot tell them apart reads an expiry as data loss.
	ChatRefs chatRefRow `json:"chatRefs"`
}

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"`
	// Attach lists upload tickets begun without a target
	// (`attachment.begin` with a family) — files pasted or dropped into
	// the composer while this was written. Each is committed against
	// the new object once it exists; a refusal is reported in
	// `attachErrors` and never undoes the write.
	Attach []string `json:"attach" validate:"max=20"`
}

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"`
	// AttachErrors names, one line each, the upload tickets in `attach`
	// that could not be committed. Absent when every file landed.
	AttachErrors []string `json:"attachErrors,omitempty"`
}

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"`
	// Attach lists upload tickets begun without a target
	// (`attachment.begin` with a family) — files pasted or dropped into
	// the composer while this was written. Each is committed against
	// the new object once it exists; a refusal is reported in
	// `attachErrors` and never undoes the write.
	Attach []string `json:"attach" validate:"max=20"`
}

type forumReplyOut struct {
	Hash   string `json:"hash"`
	Staged bool   `json:"staged"`
	// AttachErrors names, one line each, the upload tickets in `attach`
	// that could not be committed. Absent when every file landed.
	AttachErrors []string `json:"attachErrors,omitempty"`
}

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. An edit may change it — libfossil has a note retitled from
	// "basic diff generation" to "diff generation" partway through its
	// eight versions — but a note recorded WITHOUT one can never gain
	// one, because no later verb attaches the card.
	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"`
	// Time is the E card: the moment the note is FILED under, which is
	// the whole difference between a note and a forum post. Empty means
	// now on a new note, and means KEEP THE EXISTING filing time on an
	// edit — without that an edit re-files the note at the moment it
	// was edited and it silently moves in the timeline.
	Time string `json:"time" validate:"max=32"`
}

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

type chatHistoryIn struct {
	Repo   string `json:"repo" validate:"required,max=64"`
	Before uint64 `json:"before"`
	// After pages FORWARD instead: at most Limit messages with
	// seq > After, oldest first. After wins when both are set.
	After uint64 `json:"after"`
	Limit int    `json:"limit" validate:"min=0,max=500,default=100"`
}

type chatHistoryOut struct {
	Messages []chatMsgOut `json:"messages"`
	Count    int          `json:"count"`
	// Next is the seq to pass as `before` for the older page; 0 when
	// there is none (the window's start has been reached). `before` is
	// exclusive, so Next is the oldest returned message's own seq.
	Next uint64 `json:"next,omitempty"`
	// Read is the signed-in reader's chat read mark for this repository
	// (0 when anonymous, or when nothing has been marked). It rides
	// here rather than being looked up through chat.unread because
	// chat.unread OMITS a fully-read repository — a caller reading the
	// mark from there cannot tell "read up to the tip" from "never read
	// anything", and would draw the unread divider above every message.
	Read uint64 `json:"read,omitempty"`
	// Later is the seq to pass as `after` for the NEWER page, 0 when
	// this page already reaches the channel's newest message. It is
	// this page's own newest seq — computed from the answer, never
	// from a bound that might have moved.
	Later uint64 `json:"later,omitempty"`
}

type chatPostIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Text string `json:"text" validate:"required,max=4096"`
	// Ref names the seq this message replies to; 0 means no reply.
	// Must exist in the window or the post is rejected (ErrInvalid).
	Ref uint64 `json:"ref"`
}

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

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

type chatEditOut struct {
	OK  bool   `json:"ok"`
	Seq uint64 `json:"seq"` // the tombstone's own seq
}

type chatDeleteIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Seq  uint64 `json:"seq" validate:"required"`
}

type chatDeleteOut struct {
	OK  bool   `json:"ok"`
	Seq uint64 `json:"seq"` // the tombstone's own seq
}

type chatReadIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Seq  uint64 `json:"seq"` // 0 = mark read up to the channel's latest
}

type chatReadOut struct {
	OK  bool   `json:"ok"`
	Seq uint64 `json:"seq"`
}

type chatUnreadIn struct{}

type chatUnreadRepo struct {
	Unread int    `json:"unread"`
	Since  uint64 `json:"since"`
}

type chatUnreadOut struct {
	Repos map[string]chatUnreadRepo `json:"repos"`
}

type chatGetIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Seq  uint64 `json:"seq" validate:"required"`
	// Before and After ask for surrounding messages. A permalink with
	// no context answers "here is the message" when the question was
	// "what were they talking about"; both default to none so every
	// existing caller — the preview card among them — is unchanged.
	Before int `json:"before" validate:"min=0,max=50"`
	After  int `json:"after" validate:"min=0,max=50"`
}

type chatGetOut struct {
	Message chatMsgOut   `json:"message"`
	Replies []chatMsgOut `json:"replies"`
	// Earlier and Later are the context window, oldest-first, empty
	// unless asked for. MoreEarlier and MoreLater are the cursors that
	// continue in each direction, 0 when the window already reaches
	// that end of the channel.
	Earlier     []chatMsgOut `json:"earlier,omitempty"`
	Later       []chatMsgOut `json:"later,omitempty"`
	MoreEarlier uint64       `json:"more_earlier,omitempty"`
	MoreLater   uint64       `json:"more_later,omitempty"`
	// Expired is true when Seq predates the channel's current window —
	// the honest "aged out" answer rather than a 404 that looks like a
	// typo.
	Expired bool `json:"expired,omitempty"`
}

type chatStreamIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// From resumes after this seq (Last-Event-ID, overlaid by the
	// router); 0 streams new messages only.
	From uint64 `json:"from"`
}

// chatMsgOut is chat.Message plus its rendered HTML — the same
// raw-plus-HTML shape every other authored-text op returns (forumPostOut,
// wikiHTML, …), through the same sanitizer-gated markup pipeline
// (RFC 0011 §1's "markdown through the existing markup pipeline"). A
// deleted message renders no HTML — there is nothing left to render.
// chatMsgOut is chat.Message's fields flattened alongside HTML, spelled
// out rather than embedded: the ops spec reflector (spec.go) documents
// an anonymous embedded field as one nested object instead of the
// promoted, flat fields encoding/json actually puts on the wire — a
// generic gap, not worth fixing there for the one Out shape that hits
// it. Field-for-field with chat.Message plus HTML, so `.Text`, `.Seq`,
// `.Edited` etc. read exactly as they would through embedding.
type chatMsgOut struct {
	Seq     uint64 `json:"seq"`
	Kind    string `json:"kind"`
	Ref     uint64 `json:"ref,omitempty"`
	Repo    string `json:"repo"`
	User    string `json:"user"`
	Text    string `json:"text"`
	Origin  string `json:"origin,omitempty"`
	At      string `json:"at"`
	Edited  bool   `json:"edited,omitempty"`
	Deleted bool   `json:"deleted,omitempty"`
	HTML    string `json:"html"`
	// Refs are the canonical reference tokens this message carries
	// (RFC 0011 §14), so an API client can render the quotes and links
	// the web page renders without re-parsing the text.
	Refs []string `json:"refs,omitempty"`
	// Replies counts the direct replies visible alongside this message.
	Replies int `json:"replies,omitempty"`
	// RefUser and RefLine are the reply-quote: who wrote the message
	// this one answers, and its first line. Empty when the referenced
	// message is outside what this answer carries — a quote that cannot
	// be sourced is left off rather than invented.
	RefUser string `json:"refUser,omitempty"`
	RefLine string `json:"refLine,omitempty"`
}

// chatRenderOne wraps one message with its rendered HTML. repo is the
// already-resolved *Repo (never re-resolved per message: a history page
// or a reply list can carry many).
func (d Deps) chatRenderOne(ctx context.Context, repo *Repo, repoName string, m chat.Message) chatMsgOut {
	out := chatMsgOut{
		Seq: m.Seq, Kind: m.Kind, Ref: m.Ref, Repo: m.Repo, User: m.User,
		Text: m.Text, Origin: m.Origin, At: m.At, Edited: m.Edited, Deleted: m.Deleted,
	}
	if m.Deleted || m.Text == "" {
		return out
	}
	// The ctx-aware resolver, not renderBody's: a chat message may name
	// another repository, and whether that link appears is a question
	// about the reader.
	res := d.refResolver(ctx, repo.Index, repoName)
	if html, err := renderBodyWith(res, authoredMimetype, []byte(m.Text)); err == nil {
		out.HTML = html
	}
	out.Refs = refsIn(res, m.Text)
	return out
}

func (d Deps) chatRenderAll(ctx context.Context, repo *Repo, repoName string, msgs []chat.Message) []chatMsgOut {
	out := make([]chatMsgOut, len(msgs))
	for i, m := range msgs {
		out[i] = d.chatRenderOne(ctx, repo, repoName, m)
	}
	return out
}

// chatQuote fills the reply-quote and the reply counts from ONE slice
// of messages: the quote is sourced from a message the same answer
// already carries, so a page never shows a quote it cannot also show
// the original of. A reply whose parent fell outside the page keeps its
// "in reply to" link and loses only the quote.
func chatQuote(msgs []chatMsgOut) {
	bySeq := make(map[uint64]int, len(msgs))
	for i, m := range msgs {
		bySeq[m.Seq] = i
	}
	for i := range msgs {
		if msgs[i].Ref == 0 || msgs[i].Kind != chat.KindMsg {
			continue
		}
		j, ok := bySeq[msgs[i].Ref]
		if !ok {
			continue
		}
		msgs[j].Replies++
		if msgs[j].Deleted {
			continue
		}
		msgs[i].RefUser = msgs[j].User
		msgs[i].RefLine = firstLine(msgs[j].Text)
	}
}

// chatEditWindow is how long after posting a message its author may
// still edit it (RFC 0011 §1); deletion has no such limit for the
// author, matching the ops table.
const chatEditWindow = 5 * time.Minute

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"`
	// min=3 so that `tip` is accepted, as doc.get already accepts it;
	// both hand the word to the same resolver, so it means the same
	// thing on both routes (4bc7035f, G-2). Anything shorter than a
	// symbolic name is not a check-in prefix either.
	Hash string `json:"hash" validate:"required,min=3,max=64"`
	Path string `json:"path" validate:"required,max=512"`
	// From and Lines window the highlighted output. A whole large file
	// is a DOM nobody asked for — chroma emits a span per token, so a
	// 198KB source became 40,083 nodes — and the reader can see forty
	// lines of it. Zero lines means the server's own default.
	From  int `json:"from" validate:"min=0"`
	Lines int `json:"lines" validate:"min=0,max=5000"`
}

// 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.
//
// Exported because the SEARCH indexer must use the same rule as the
// docs listing. Two definitions of "is this a document" is how a
// listing and a search come to disagree about what a repository holds,
// and the disagreement is invisible until somebody cannot find a page
// they are looking at.
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"`
	// Plain says why HTML is absent when the content was renderable but
	// deliberately not highlighted.
	Plain string `json:"plain,omitempty"`
	// From is the first line HTML covers; NextFrom is where the window
	// after it begins, and is zero at the end of the file. TotalLines is
	// known only once a window has reached the end.
	From       int `json:"from"`
	NextFrom   int `json:"nextFrom,omitempty"`
	TotalLines int `json:"totalLines,omitempty"`
}

// defaultFileWindow is how many lines a file page draws before asking
// for more. A few screens: enough that a short file arrives whole and a
// reader rarely waits, few enough that a 25,000-line file does not
// build a 40,000-node DOM to show its first page.
const defaultFileWindow = 400

type attachmentListIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Target is the object the A card names: a ticket id, a wiki page
	// NAME, a technote event id, or a forum post hash. Fossil's A card
	// carries one opaque target string and quarry keeps it opaque — the
	// caller already knows which kind of object it is asking about.
	Target string `json:"target" validate:"required,max=200"`
}

type attachmentListOut struct {
	Attachments []index.Attachment `json:"attachments"`
}

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

// maxHeadLines is how many lines of a text attachment a card preview
// holds. Eight: enough that a log or a diff shows what it is, few
// enough that a page carrying a dozen files is still a page. The cap is
// the OP's and not the caller's — a card is the only consumer, and a
// caller asking for a thousand lines would turn a summary into a second
// copy of the file.
const maxHeadLines = 8

// attachHeadPrefix is how much of a blob deciding its shape is allowed
// to read. 64 KiB: every signature this recognises is in the first
// twelve bytes, and eight lines of text is a few hundred.
//
// The whole blob was read here once, through a subprocess, on every
// render of every page listing a file — up to 32 MiB each, and an
// attachment past that cap failed its card outright. A prefix answers
// the same question, and the memo below means it is asked once.
const attachHeadPrefix = 64 << 10

type attachmentHeadIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Hash is the attachment's CONTENT artifact, the same address the
	// download route takes.
	Hash string `json:"hash" validate:"required,min=4,max=64"`
	// Lines is how many lines of a text attachment to preview, capped
	// at maxHeadLines. Zero means the cap.
	Lines int `json:"lines" validate:"min=0,max=1000"`
}

// attachmentHeadOut is what a card needs and the download does not: how
// big the file is, which of the four shapes it takes, and — for text —
// the first few lines to draw behind the name.
type attachmentHeadOut struct {
	Name string `json:"name"`
	Size int    `json:"size"`
	// Kind is image, text, media or other, decided from the bytes.
	Kind string `json:"kind"`
	// Mimetype is the SNIFFED type for an image or a media file, empty
	// for text and for everything else. Not the N card: the declared
	// type is the attacher's claim and nothing that draws bytes may
	// decide from it.
	Mimetype string `json:"mimetype,omitempty"`
	// Lines is how many lines the file holds when it is text, so a
	// badge can say so; Text is the first maxHeadLines of them.
	Lines int    `json:"lines,omitempty"`
	Text  string `json:"text,omitempty"`
	// Truncated says the prefix read stopped before the end of the
	// file, so Lines counts the lines SEEN rather than the lines there
	// are. A card says "8+ lines" rather than claiming a total it was
	// never told.
	Truncated bool `json:"truncated,omitempty"`
	// External says the blob is a stub (RFC 0016): Size, Kind and
	// Mimetype then come from the stub's declaration rather than from
	// bytes, SHA256 names the object, and Held says nobody has vouched
	// for it yet. Kind is "missing" with a Reason when the bytes are
	// out of reach — no bucket is bound to the repository.
	External bool   `json:"external,omitempty"`
	SHA256   string `json:"sha256,omitempty"`
	Held     bool   `json:"held,omitempty"`
	Reason   string `json:"reason,omitempty"`
}

// attachKindMissing is the card kind for a file whose bytes are out of
// reach: a stub with no bucket to point at. Not one of sniff's kinds,
// because nothing was sniffed.
const attachKindMissing = "missing"

// headOut dresses a remembered verdict as this op's answer. The line
// window is the caller's, so a memo written for one caller serves
// another asking for fewer lines.
func headOut(name string, h index.AttachmentHead, lines int) attachmentHeadOut {
	if lines <= 0 || lines > maxHeadLines {
		lines = maxHeadLines
	}
	out := attachmentHeadOut{
		Name: name, Size: h.Size, Kind: h.Kind, Mimetype: h.Mimetype,
		Lines: h.Lines, Truncated: h.Truncated,
	}
	if h.Text != "" {
		all := strings.Split(h.Text, "\n")
		if len(all) > lines {
			all = all[:lines]
		}
		out.Text = strings.Join(all, "\n")
	}
	return out
}

// attachmentHead is the classification itself, kept apart from the op
// so it can be exercised without a store: everything here is a decision
// about a byte slice.
//
// prefix is the FRONT of the file, not the file. size is the whole
// file's, which the store answers separately. truncated says the prefix
// stopped short, which is what makes a line count a floor rather than a
// fact.
func attachmentHead(prefix []byte, size int, truncated bool) index.AttachmentHead {
	body := prefix
	if truncated {
		// The bound cuts wherever it falls, routinely mid-character.
		// Judging that tail invalid would make every long UTF-8
		// document binary at random.
		body = sniff.TrimPartialRune(prefix)
	}
	h := index.AttachmentHead{Size: size, Kind: sniff.Kind(body), Truncated: truncated}
	switch h.Kind {
	case sniff.KindImage:
		h.Mimetype, _ = sniff.Image(body)
	case sniff.KindMedia:
		h.Mimetype, _ = sniff.Media(body)
	case sniff.KindText:
		all := strings.Split(strings.TrimSuffix(string(body), "\n"), "\n")
		if truncated && len(all) > 1 {
			// The last line of a cut prefix is half a line. Counting it
			// would overstate by one and previewing it would show a
			// sentence that stops mid-word.
			all = all[:len(all)-1]
		}
		h.Lines = len(all)
		if len(all) > maxHeadLines {
			all = all[:maxHeadLines]
		}
		h.Text = strings.Join(all, "\n")
	}
	return h
}

// attachmentGetAnswer turns an attachment's stored bytes into the op's
// answer, and is separate from the op so the decision can be tested
// without a repository behind it.
//
// A STUB IS NOT THE FILE. RFC 0016 puts an uploaded attachment's bytes
// in the repository's bucket and writes a few hundred bytes of
// declaration into the DAG in their place. Serving what this route
// stores therefore handed back the declaration wearing the file's real
// name and the declaration's length: a caller asking for a 17 MB video
// got 143 bytes of text that looked like a complete file, with nothing
// in the answer saying otherwise.
//
// The BYTES decide rather than the index row. They are already in hand,
// they cannot disagree with themselves, and a fold that missed the
// shape must not become a route that serves it.
func attachmentGetAnswer(hash, name string, raw []byte) (attachmentGetOut, error) {
	if !attach.IsStub(raw) {
		return attachmentGetOut{Name: name, Size: len(raw),
			Content: base64.StdEncoding.EncodeToString(raw)}, nil
	}
	stub, err := attach.Parse(raw)
	if err != nil {
		// Something that opens like a stub and does not parse is not a
		// file either. Refusing beats guessing, and guessing here means
		// handing somebody a broken download they will not notice.
		return attachmentGetOut{}, fmt.Errorf(
			"attachment %q is an unreadable external declaration: %w", hash, ops.ErrNotFound)
	}
	// This op's contract is the CONTENT, and for an external attachment
	// it has none to give. It used to answer 200 with the stub's bytes,
	// which served the declaration as the file; answering 200 with the
	// file's size and empty content fixed the dangerous half and left a
	// smaller one, because a client that writes what it is given now
	// writes a nought-byte file under a name that says 49 KB. Reported
	// by jva after re-measuring the first fix.
	//
	// So it refuses. An op that cannot do what it says should say where
	// to go instead of succeeding with a hole in it, and 400 is the one
	// answer no client reads as bytes.
	return attachmentGetOut{}, fmt.Errorf(
		"%s is external: its %d bytes are in the repository's bucket, not in this route; "+
			"GET /api/v1/r/{repo}/attachment/{hash}/link answers where (sha256 %s): %w",
		name, stub.Size, stub.SHA256, ops.ErrInvalid)
}

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.
	//
	// An external attachment never reaches here — the op refuses rather
	// than answering a body it does not have. See attachmentGetAnswer.
	Content string `json:"content"`
}

// CIDecline is an admin's recorded answer to "does this repository use
// CI at all". It lives in the forge database rather than the repository
// because it governs the forge's own navigation, and a tracked file
// would clone to everyone who copied the repository and mean nothing
// to them.
type CIDecline interface {
	CIDeclined(repo string) (who, at string)
}

// 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)
	// CIRunsPage is CIRuns from an offset: the CI page pages rather
	// than growing without bound.
	CIRunsPage(repo string, limit, offset int) ([]CIRun, error)
	CIRunsFor(repo, checkin string) ([]CIRun, error)
	CIRunsForPair(repo, pair string) ([]CIRun, error)
}

// CIRun is one launched pipeline, as the display needs it. URL is set
// only on external checks (RFC 0009): their reporter's own log viewer.
type CIRun struct {
	URL      string `json:"url,omitempty"`
	RunID    string `json:"runId"`
	Checkin  string `json:"checkin"`
	Pair     string `json:"pair,omitempty"`
	Workflow string `json:"workflow"`
	Pipeline string `json:"pipeline"`
	Status   string `json:"status"`
	Started  string `json:"started"`
	// Completed is the terminal stamp; "" while running. Branch,
	// Comment and User are the check-in's own story, joined in by
	// ci.list so the list can answer "what was this run about"
	// without a lookup per row.
	Completed string `json:"completed,omitempty"`
	Branch    string `json:"branch,omitempty"`
	Comment   string `json:"comment,omitempty"`
	User      string `json:"user,omitempty"`
}

// ciPipelineSaveIn is the editor's one verb: validate, or commit to a
// fresh branch and open a landing.
type ciPipelineSaveIn struct {
	Repo    string `json:"repo" validate:"required,max=64"`
	Path    string `json:"path" validate:"required,max=120"`
	Content string `json:"content" validate:"required,max=262144"`
	Message string `json:"message" validate:"max=500"`
	// Validate asks for diagnostics only; nothing is written.
	Validate bool `json:"validate"`
}

type ciPipelineSaveOut struct {
	Diagnostics []string `json:"diagnostics,omitempty"`
	Hash        string   `json:"hash,omitempty"`
	Branch      string   `json:"branch,omitempty"`
	Landing     string   `json:"landing,omitempty"`
}

// validatePipelinePath keeps the editor inside ci/: one segment, one
// of the pipeline extensions, no way to aim a write anywhere else.
func validatePipelinePath(path string) error {
	rest, ok := strings.CutPrefix(path, "ci/")
	if !ok || rest == "" || strings.Contains(rest, "/") || strings.Contains(rest, "\\") ||
		strings.HasPrefix(rest, ".") || hasControl(rest) {
		return fmt.Errorf("a pipeline lives directly in ci/: %w", ops.ErrInvalid)
	}
	switch {
	case strings.HasSuffix(rest, ".json"), strings.HasSuffix(rest, ".yml"), strings.HasSuffix(rest, ".yaml"):
		return nil
	}
	return fmt.Errorf("a pipeline is .json, .yml or .yaml: %w", ops.ErrInvalid)
}

// pipelineDiagnostics judges content the way the run path would: JSON
// locally (dagnats' native format needs a name and steps), yml at the
// engine when the forge can ask it.
func (d Deps) pipelineDiagnostics(path, content string) []string {
	if strings.HasSuffix(path, ".json") {
		var doc struct {
			Name  string `json:"name"`
			Steps []struct {
				ID   string `json:"id"`
				Task string `json:"task"`
			} `json:"steps"`
		}
		if err := json.Unmarshal([]byte(content), &doc); err != nil {
			return []string{"not JSON: " + err.Error()}
		}
		var diags []string
		if doc.Name == "" {
			diags = append(diags, "the workflow has no name")
		}
		if len(doc.Steps) == 0 {
			diags = append(diags, "the workflow has no steps")
		}
		for i, s := range doc.Steps {
			if s.ID == "" || s.Task == "" {
				diags = append(diags, fmt.Sprintf("step %d needs both id and task", i+1))
			}
		}
		return diags
	}
	if d.ValidateCIYAML == nil {
		return nil // no engine to ask; it judges at register time
	}
	stem := strings.TrimSuffix(strings.TrimSuffix(pathpkg.Base(path), ".yml"), ".yaml")
	diags, err := d.ValidateCIYAML(stem, []byte(content))
	if err != nil {
		return []string{err.Error()}
	}
	return diags
}

type checkReportIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Checkin is the subject: a check-in hash, or a premerge pair in
	// the spelling quarry-premerge prints — pair:<branchTip>:<targetTip>.
	Checkin string `json:"checkin" validate:"required,min=4,max=200"`
	Context string `json:"context" validate:"required,max=100"`
	State   string `json:"state" validate:"required,oneof=pending|success|failure|error"`
	URL     string `json:"url" validate:"max=300"`
	Summary string `json:"summary" validate:"max=2048"`
	Token   string `json:"token" validate:"required,max=128"`
}

type checkReportOut struct {
	OK      bool   `json:"ok"`
	Context string `json:"context"`
	State   string `json:"state"`
}

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

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

type ciTokenNewIn struct {
	Repo  string `json:"repo" validate:"required,max=64"`
	Label string `json:"label" validate:"max=100"`
}

type ciTokenNewOut struct {
	// Token exists in the clear here and nowhere else.
	Token string `json:"token"`
}

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

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

type ciTokenRow struct {
	Hash    string `json:"hash"`
	Label   string `json:"label"`
	Created string `json:"created"`
	// Used is when the token last reported, absent for one that never
	// has: the difference between a credential in service and one left
	// behind, which is what makes a list of them revocable.
	Used string `json:"used,omitempty"`
}

type ciTokenListOut struct {
	Tokens []ciTokenRow `json:"tokens"`
	Count  int          `json:"count"`
}

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

type ciListOut struct {
	Runs  []CIRun `json:"runs"`
	Count int     `json:"count"`
	// Bypasses are the gate tripwire's findings: check-ins that moved
	// a protected branch without a forge accept behind them.
	Bypasses []oltp.GateBypass `json:"bypasses,omitempty"`
}

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

// archiveFile is archive.get's answer: an ops.File whose bytes are
// produced by Open, so the ETag — the resolved check-in plus the
// container — is answered before any build runs. The archive is a pure
// function of those two and zit guarantees the bytes are reproducible,
// so the entity can never change and a reader who has it downloads
// nothing.
type archiveFile struct {
	name, ctype, version, format string
	// build produces the file and says whether it is a temporary one
	// this reader must remove after use.
	build func() (path string, temp bool, err error)
}

func (a archiveFile) Name() string        { return a.name }
func (a archiveFile) ContentType() string { return a.ctype }

// ETag names the bytes only when version is a full hash: that is the
// one case the archive can never change. A ref resolved to nothing
// better than itself — `tip`, `tag:x` — gets no ETag, so it is neither
// cached for a year nor answered 304 after the ref moves.
func (a archiveFile) ETag() string {
	if !fullHash(a.version) {
		return ""
	}
	return a.version + "-" + a.format
}

// fullHash is a complete artifact hash: 40 or 64 lowercase hex digits.
func fullHash(s string) bool {
	if len(s) != 40 && len(s) != 64 {
		return false
	}
	for _, c := range s {
		if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
			return false
		}
	}
	return true
}
func (a archiveFile) Open() (io.ReadCloser, int64, error) {
	path, temp, err := a.build()
	if err != nil {
		return nil, 0, err
	}
	f, err := os.Open(path)
	if err != nil {
		return nil, 0, fmt.Errorf("%w: %v", ops.ErrUnavailable, err)
	}
	fi, err := f.Stat()
	if err != nil {
		f.Close()
		return nil, 0, fmt.Errorf("%w: %v", ops.ErrUnavailable, err)
	}
	if temp {
		return removeOnClose{f, path}, fi.Size(), nil
	}
	return f, fi.Size(), nil
}

// removeOnClose is a temporary archive that outlives its build only as
// long as the reader that asked for it.
type removeOnClose struct {
	*os.File
	path string
}

func (r removeOnClose) Close() error {
	err := r.File.Close()
	os.Remove(r.path)
	return err
}

// branchAliasIn asks what a branch NAME really resolves to.
type branchAliasIn struct {
	Repo   string `json:"repo" validate:"required,max=64"`
	Branch string `json:"branch" validate:"required,max=200"`
}

// branchAliasOut answers the question protection has to ask before it
// trusts a rule: is this name the one a pushed check-in will resolve
// to, or another name for a branch that answers to something else?
type branchAliasOut struct {
	Branch string `json:"branch"`
	// Alias is the name check-ins on this branch actually carry, empty
	// when Branch is already that name or when nothing carries it yet.
	Alias   string `json:"alias,omitempty"`
	IsAlias bool   `json:"isAlias,omitempty"`
}

type branchListIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Limit and Open exist because a SELECTOR is not a listing. The
	// tree page drew a picker of twenty-five and fetched all 1,759 of
	// sqlite's branches to do it — 478ms of a 520ms page, spent
	// building and marshalling rows nobody saw. Zero means every one,
	// which the branch listing page still wants.
	Limit int  `json:"limit" validate:"min=0,max=5000"`
	Open  bool `json:"open"`
}

// branchRow is a branch as the LIST publishes it: what the index knows,
// plus the protection rule that governs it.
//
// The index knows nothing of protection — it is forge-local policy, the
// same split as attachment holds — so the joining happens in the ops
// layer, once, where every caller of the list gets it.
type branchRow struct {
	index.Branch
	// Protected and Owners are RFC 0007's rule as it applies to THIS
	// branch, resolved through the same glob matcher the sync gate and
	// the settings page use, so all three agree about what a rule
	// covers.
	//
	// They are published to everyone who may read the branch list,
	// including the developers the rule constrains, because until now
	// only an admin could learn a branch was protected — the people it
	// binds found out by being refused at push time (4a6da2fa932b,
	// measured by jva from the constrained seat).
	//
	// Safe to publish by the test my colleague proposed: the push
	// refusal already names the branch and its rule out loud, so this
	// tells nobody anything they could not get by pushing once. It
	// tells them EARLIER, while it can still change what they do.
	//
	// Required CHECK NAMES are deliberately not here. Nothing makes
	// them say themselves, so publishing them would describe a
	// repository's CI to anyone who can read a branch list — a
	// different disclosure from "who owns trunk", and one that wants
	// deciding rather than assuming.
	Protected bool     `json:"protected,omitempty"`
	Owners    []string `json:"owners,omitempty"`
}

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

// withProtection joins each branch to the rule that governs it.
//
// A rule source that cannot answer leaves the rows unmarked rather than
// failing the listing: a branch list is a read, and losing it entirely
// because policy was unavailable would be worse than losing the marker.
// The enforcement path fails CLOSED on its own read faults; this one
// only ever under-reports, and never claims a branch is unprotected in
// a way anything relies on.
func (d Deps) withProtection(repo string, bs []index.Branch) []branchRow {
	rows := make([]branchRow, 0, len(bs))
	for _, b := range bs {
		rows = append(rows, branchRow{Branch: b})
	}
	if d.Protect == nil || len(rows) == 0 {
		return rows
	}
	rules, err := d.Protect.Protections(repo)
	if err != nil {
		log.Printf("branch.list: %s: reading protection: %v", repo, err)
		return rows
	}
	for i := range rows {
		for _, r := range rules {
			if oltp.BranchMatches(r.Branch, rows[i].Name) {
				rows[i].Protected = true
				rows[i].Owners = append(rows[i].Owners, r.Owners...)
			}
		}
	}
	return rows
}

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|doc|file"`
	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|doc|file"`
	// 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"`
	// Appearances keeps each repository's own figures instead of only
	// their sum. A contributor is a NAME ON AN ARTIFACT — fossil's U
	// card is a bare string with no address and no key — so "dan" in
	// sqlite (7,002 check-ins, Dan Kennedy since 2007) and "dan" on
	// this forge are not known to be one person, and mostly are not.
	// Summing them invents a contributor who has never existed; the
	// breakdown lets a reader see the seam the data actually has.
	Appearances []appearance `json:"appearances"`
}

// appearance is one repository's record of one name.
type appearance struct {
	Repo   string `json:"repo"`
	Events int    `json:"events"`
	First  string `json:"first"`
	Last   string `json:"last"`
}

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"`
	// Branch widens the diff to the whole branch Hash sits on: the
	// branch's tip against the check-in it forked from. Fossil names it
	// the same way — by a check-in, not a branch name, so the link on a
	// check-in page needs nothing it does not already have.
	//
	// It RESOLVES to a pair of endpoints and then runs the ordinary
	// diff, so windowing, the structured model and the patch route all
	// work unchanged. This is a way of naming two check-ins, not a
	// second diff engine.
	Branch bool `json:"branch,omitempty"`
	// From diffs against this check-in instead of Hash's parent. A
	// landing needs branch-against-target, which is not a parent
	// relationship: the branch has moved on since it forked, and the
	// target has moved under it. Empty keeps the parent behaviour every
	// existing caller relies on.
	From string `json:"from,omitempty" validate:"max=64"`
	// Unified asks for the raw patch as well. Off by default: it is a
	// verbatim copy of what Structured already carries, and the page —
	// the only caller in the tree — never read it, so every check-in
	// view was serialising ~78KB of duplicate and parsing it back.
	Unified bool `json:"unified,omitempty"`
}

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 is the raw patch, returned only when asked for — what a
	// script wants, and dead weight for a page that renders Structured.
	Unified string `json:"unified,omitempty"`
	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"`
	// CIRun is the engine run that produced that verdict, when quarry
	// launched one. The verdict alone is a word with nowhere to go —
	// the reader who cares WHY has to find the run by hand.
	CIRun   string           `json:"ciRun,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"`
	// ChatRefs is the WINDOW-BOUNDED count of times chat named this
	// artifact (RFC 0011 §14). Nil when nothing has, or when the forge
	// keeps no chat refs. Deliberately not folded into Mentions: those
	// are durable index facts and these expire with the channel, and a
	// reader who cannot tell them apart reads an expiry as data loss.
	ChatRefs chatRefRow `json:"chatRefs"`
	// Tags is the symbolic tags standing on this check-in, branch
	// names excluded — one indexed lookup, no store read.
	Tags []string `json:"tags,omitempty"`
	// CommentOrig is the C card a later `comment` tag replaced, set
	// only when one did. Hidden and Closed are the standing flags.
	CommentOrig string `json:"commentOrig,omitempty"`
	Hidden      bool   `json:"hidden,omitempty"`
	Closed      bool   `json:"closed,omitempty"`
	// Fork is the check-in this check-in's BRANCH grew from, empty on
	// a branch that forked from nothing. A page offers the branch diff
	// only when there is something to diff against — an offered link
	// that cannot work is worse than an absent one.
	Fork string `json:"fork,omitempty"`
	// Context is this check-in among its neighbours in time, newest
	// first, with this one in the middle. One indexed walk each way —
	// no store read, so it costs nothing a page was not already paying.
	Context []index.TimelineEntry `json:"context,omitempty"`
}

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

// ManifestCard is one card of a manifest, in the order it appears.
type ManifestCard struct {
	Letter string `json:"letter"`
	Text   string `json:"text"`
	// Note explains what the card IS, for the four-fifths of readers
	// who have not memorised §4.7 of the format RFC.
	Note string `json:"note,omitempty"`
}

type manifestGetOut struct {
	Hash string `json:"hash"`
	Size int    `json:"size"`
	// Cards is everything EXCEPT the file list — in a real repository
	// that is eight cards out of nine hundred, and it is the part a
	// reader came for.
	Cards []ManifestCard `json:"cards"`
	// Files is the F cards, counted rather than returned: the tree
	// browser renders the same set with paths that are links, and
	// serving 52KB of them a second time helps nobody.
	Files int `json:"files"`
}

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

// checkinCloseIn closes one leaf. Reopen takes the same shape, which
// is why the verb is a field rather than two nearly identical ops.
type checkinCloseIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Hash string `json:"hash" validate:"required,min=4,max=64"`
	// Reopen undoes it. Fossil's tag log is append-only, so reopening
	// is a cancelling artifact rather than a deletion — the history
	// keeps both, which is the point of a log.
	Reopen bool `json:"reopen"`
}

type checkinCloseOut struct {
	Hash   string `json:"hash"`
	Closed bool   `json:"closed"`
}

type checkinTagsOut struct {
	Hash string `json:"hash"`
	Date string `json:"date"`
	User string `json:"user"`
	// Comment is what a reader is shown; CommentOrig is what it
	// replaced, when a `comment` tag corrected the check-in after the
	// fact.
	Comment     string           `json:"comment"`
	CommentOrig string           `json:"commentOrig,omitempty"`
	Branch      string           `json:"branch"`
	Hidden      bool             `json:"hidden,omitempty"`
	Closed      bool             `json:"closed,omitempty"`
	Events      []index.TagEvent `json:"events"`
}

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"`
	// Origin narrows by tier: local (this forge's intake queue) or
	// fossil (the artifact log). Empty lists both.
	Origin string `json:"origin" validate:"oneof=local|fossil"`
}

// 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"`
	// Assignee is whoever the tracker says holds it, empty for nobody.
	// A tracker-defined field rather than one of fossil's ten, so a
	// tracker without the column leaves every row empty — which reads
	// as unassigned and is the honest answer for it (RFC 0018's board).
	Assignee string `json:"assignee,omitempty"`
	// Refs counts the landings that named this ticket — "something
	// proposes to fix this", the tracker's version of GitHub's linked
	// pull request.
	Refs int `json:"refs"`
	// Local marks a row from the local tier (docs/local-tickets.md):
	// filed on this forge, absent from the artifact log until promoted.
	Local bool `json:"local,omitempty"`
}

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

// A landing (zit#127) is a branch proposed to land on a target. It is not a
// pull request: nothing is transported, because the branch is already in the
// store. What the record adds is the middle that was missing — something to
// review, comment on, accept or reject, and fire CI from.
//
// Every op here rides the capability of the artifact it actually mints. Reads
// and the discussion are forum caps, because a landing IS a forum thread.
// accept is the exception and the reason this needed deciding: it writes a
// merge CHECK-IN, so it takes `i` — the letter RFC 0004 reserved for exactly
// that and left inert until a surface needed it.
// landingPost is one post in a record's thread, CLASSIFIED. zit writes a
// state change as a reply because a reply is an artifact fossil understands;
// a reader wants it as an event. The API does the telling-apart because it
// holds the raw body — a consumer re-deriving it from rendered HTML would be
// a second reader of the same dialect, and the two would drift.
type landingPost struct {
	forumRendered
	// Transition is the state this post carries, empty for a remark.
	Transition string `json:"transition,omitempty"`
	// Updated is the merge this reply recorded when the target was
	// brought into the branch on the store (zit#152); empty otherwise.
	Updated string `json:"updated,omitempty"`
	// Retarget names the branch this reply moved the record onto
	// (zit#144), empty on every other post.
	Retarget string `json:"retarget,omitempty"`
	// TitleIsBody says the opener carried no prose of its own, so this
	// post's HTML is the TITLE rendered. The record then has no subject
	// line, and a page that draws one is inventing it.
	TitleIsBody bool `json:"titleIsBody,omitempty"`
	// Root marks the opener, whose body is the record's description
	// rather than a comment on it.
	Root bool `json:"root,omitempty"`
}

type landingOpenIn struct {
	Repo    string `json:"repo" validate:"required,max=64"`
	Branch  string `json:"branch" validate:"required,max=120"`
	Target  string `json:"target" validate:"max=120"`
	Message string `json:"message" validate:"required,max=65536"`
}

type landingOpenOut struct {
	ID string `json:"id"`
}

type landingSayIn struct {
	Repo    string `json:"repo" validate:"required,max=64"`
	ID      string `json:"id" validate:"required,min=4,max=64"`
	Message string `json:"message" validate:"max=65536"`
	// To aims a remark at one post in the record's thread rather than at
	// the record itself. comment only: a transition settles the record,
	// not a sub-thread of it.
	To string `json:"to" validate:"max=64"`
	// Override is accept only (RFC 0009): the stated reason for landing
	// over a red or missing required check. Empty means no override, and
	// an unmet required set refuses the accept. Recorded as a comment on
	// the record before the accept is written.
	Override string `json:"override" validate:"max=2048"`
}

// landingAutoIn arms or disarms auto-land on one record.
type landingAutoIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	ID   string `json:"id" validate:"required,min=4,max=64"`
	On   bool   `json:"on"`
}

type landingAutoOut struct {
	AutoBy string `json:"autoBy,omitempty"`
	// AutoWaiting is what the last sweep ran into, so an armed record
	// says why it has not fired instead of looking idle beside a green
	// tick. Empty when no sweep has reported anything yet, which for a
	// fresh arm means "it has not tried".
	AutoWaiting string `json:"autoWaiting,omitempty"`
	// AutoWaitingAt is when that reason was recorded. The AGE is the
	// diagnostic, not the reason: a wait from nineteen seconds ago is
	// the sweep working, and the same words from four minutes ago are
	// an arm that is stuck. Without it a reader cannot tell those
	// apart, which is the question that sent somebody to the log.
	AutoWaitingAt string `json:"autoWaitingAt,omitempty"`
}

// landingLandIn names the rung to land UP TO, inclusive. The base of a
// standalone landing is itself, so this is also an ordinary accept.
type landingLandIn struct {
	Repo    string `json:"repo" validate:"required,max=64"`
	ID      string `json:"id" validate:"required,min=4,max=64"`
	Message string `json:"message" validate:"max=65536"`
}

// landingLandOut says what happened, rung by rung. A stop is not an
// error: what landed, landed, and the rest is still a valid strata —
// by derivation, the landed rungs simply are not rungs any more.
type landingLandOut struct {
	Landed []string `json:"landed"`
	// Stopped names the rung that would not land and Reason says why;
	// both empty when everything asked for landed.
	Stopped string `json:"stopped,omitempty"`
	Reason  string `json:"reason,omitempty"`
	// Remaining is how many rungs up to the one asked for are still open.
	Remaining int `json:"remaining"`
}

type landingSayOut struct {
	// Hash names what this write produced: the post for a comment or a
	// reject, the merge check-in for an accept. Never a verb.
	Hash string `json:"hash"`
	// State is the record's state after this write: unchanged for a
	// comment, settled for accept and reject.
	State string `json:"state"`
}

// HookAdmin is the webhook half the API needs and the store cannot do
// alone: reading what an endpoint received, sending one of those again,
// and sending a test. Every method takes the repository because the
// ids name rows in a table the whole forge shares.
type HookAdmin interface {
	Deliveries(repo string, sub int64, limit int) ([]HookDelivery, error)
	// Both answer the ENDPOINT's outcome as data. An error means the
	// forge could not attempt the send at all — no such endpoint, no
	// such delivery — never that the endpoint refused or was unreachable.
	Redeliver(repo string, id int64) (webhook.Outcome, error)
	Ping(repo string, sub int64) (webhook.Outcome, error)
}

// HookDelivery is one attempt, as the API reports it. The body is not
// carried: it is kept for replay, and a listing that returned it would
// hand out event payloads to anyone who may read settings.
type HookDelivery struct {
	ID      int64  `json:"id"`
	Sub     int64  `json:"sub"`
	Kind    string `json:"kind"`
	At      string `json:"at"`
	Status  int    `json:"status"`
	Ms      int    `json:"ms"`
	Err     string `json:"err,omitempty"`
	Attempt int    `json:"attempt"`
}

type hookDeliveriesIn struct {
	Repo  string `json:"repo" validate:"required,max=64"`
	Sub   int64  `json:"sub" validate:"required,min=1"`
	Limit int    `json:"limit" validate:"min=0,max=100,default=10"`
}

type hookDeliveriesOut struct {
	Deliveries []HookDelivery `json:"deliveries"`
	Count      int            `json:"count"`
}

type hookSendIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// ID is a delivery for redeliver and a subscription for ping — the
	// two verbs act on different things and each says which in its doc.
	ID int64 `json:"id" validate:"required,min=1"`
}

type hookSendOut struct {
	// Sent says the endpoint accepted the delivery (answered 2xx).
	// Status, Ms and Err are the endpoint's own answer — a 503 or a
	// dial failure is the RESULT of a ping, not a failure to ping, so
	// it comes back here on a 200 with the delivery already recorded.
	// It used to come back as a 500 after the row was written, so a
	// client that trusted the status retried and double-delivered.
	Sent   bool   `json:"sent"`
	Status int    `json:"status"`
	Ms     int    `json:"ms"`
	Err    string `json:"err,omitempty"`
}

// unproposedIn asks one repository what work is going nowhere.
type unproposedIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
}

type unproposedRow struct {
	Branch string `json:"branch"`
	Tip    string `json:"tip"`
	Date   string `json:"date"`
	// Target is where it would land — reported so a caller proposing
	// one does not have to ask a second op what trunk is called.
	Target string `json:"target"`
}

type unproposedOut struct {
	Rows  []unproposedRow `json:"rows"`
	Count int             `json:"count"`
}

type landingListIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// State filters to open, accepted or rejected; empty is everything,
	// and `all` says so explicitly.
	//
	// `all` is listed because the web page's own filter links to it. A
	// client written by reading this product's UI asked for the one
	// value the op did not accept, and was answered 200 with count 0 --
	// indistinguishable from a repository with no landings, on a page
	// whose whole purpose is to say what is awaiting decision (G-132).
	// The enum is declared here rather than checked in the handler so
	// the refusal names the accepted words, as every other filter on
	// this API already does.
	State string `json:"state" validate:"oneof=|open|accepted|rejected|all,max=16"`
	Limit int    `json:"limit" validate:"min=0,max=200,default=50"`
}

type landingListOut struct {
	Landings []index.LandingSummary `json:"landings"`
	// Open is the whole tracker's open count, not this page's — the
	// header answers "how much is awaiting decision" before any filter.
	Open  int `json:"open"`
	Count int `json:"count"`
}

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

// ProtectStore is the branch-protection policy (RFC 0007), forge OLTP.
type ProtectStore interface {
	MayJoin(repo, user, branch string) bool
	Protections(repo string) ([]oltp.Protection, error)
}

// ChecksStore answers RFC 0009's required-checks policy.
type ChecksStore interface {
	RequiredChecks(repo, branch string) []string
	// PremergeMode says the branch is tested as the merged tree: the
	// subject of runs and reports is the PAIR of tips, and a moved
	// target expires every verdict by construction.
	PremergeMode(repo, branch string) bool
}

// AutoStore is the armed auto-accept set (RFC 0009 §verdicts). The
// capability to arm is checked when arming; protection and the
// required set are the fire-time gates, in landing.accept's one write
// path.
// GateStore is the tripwire's two verbs: remember a forge accept,
// and read the findings for the CI page.
type GateStore interface {
	RecordForgeAccept(repo, checkin string) error
	GateBypasses(repo string, limit int) ([]oltp.GateBypass, error)
	// A tripwire needs a reset and a history: acknowledging hides a
	// finding from the strip, never deletes it; the history pages
	// every finding ever made.
	GateBypassHistory(repo string, offset, limit int) ([]oltp.GateBypass, int, error)
	AckGateBypass(repo, checkin string) error
	AckAllGateBypasses(repo string) (int, error)
}

type AutoStore interface {
	SetAutoAccept(repo, landing, account string) error
	ClearAutoAccept(repo, landing string) error
	AutoAcceptOf(repo, landing string) (string, bool)
}

// ExternalCI is the external-checks door (RFC 0009 §external-ci).
type ExternalCI interface {
	CheckReporterToken(repo, token string) bool
	UpsertExternalCheck(repo string, c oltp.ExternalCheck) error
	ExternalChecks(repo, subject string) ([]oltp.ExternalCheck, error)
	MintReporterToken(repo, label string) (string, error)
	ReporterTokens(repo string) ([]oltp.ReporterToken, error)
	RevokeReporterToken(repo, hash string) error
}

// protectionOf reports whether a branch is protected and by whom: the
// union of owners across every rule that matches, for the page to name.
// PairVerdict judges one premerge pair against a target branch's
// required set: every required check green from the pair's runs and
// reports is "ci-pass", any of them failed is "ci-fail", anything
// else — nothing ran, or it is still running — is "" (RFC 0009).
func (d Deps) PairVerdict(repo, pair, target string) string {
	if d.Checks == nil {
		return ""
	}
	required := d.Checks.RequiredChecks(repo, target)
	if len(required) == 0 {
		return ""
	}
	green, red := map[string]bool{}, map[string]bool{}
	if d.CI != nil {
		if runs, err := d.CI.CIRunsForPair(repo, pair); err == nil {
			for _, r := range runs {
				switch {
				case passedStatus(r.Status):
					green[r.Workflow], green[r.Pipeline] = true, true
				case r.Status == "failed" || r.Status == "error" || r.Status == "cancelled" || r.Status == "canceled":
					red[r.Workflow], red[r.Pipeline] = true, true
				}
			}
		}
	}
	if d.External != nil {
		if ecs, err := d.External.ExternalChecks(repo, pair); err == nil {
			for _, ec := range ecs {
				switch ec.State {
				case "success":
					green[ec.Context] = true
				case "failure", "error":
					red[ec.Context] = true
				}
			}
		}
	}
	verdict := "ci-pass"
	for _, name := range required {
		if red[name] && !green[name] {
			return "ci-fail"
		}
		if !green[name] {
			verdict = ""
		}
	}
	return verdict
}

// ciSubject answers what a landing's checks are ABOUT: the branch tip
// in tip mode, the pair of tips in premerge mode (RFC 0009 §premerge).
// The pair spelling matches internal/premerge.Pair.
func (d Deps) ciSubject(repoName string, l index.LandingSummary) (subject string, premerge bool) {
	repo, ok := d.Repos.Get(repoName)
	if !ok || repo.Index == nil {
		return "", false
	}
	tip, err := repo.Index.BranchTip(l.Branch)
	if err != nil || tip == "" {
		return "", false
	}
	if d.Checks != nil && d.Checks.PremergeMode(repoName, l.Target) {
		target, terr := repo.Index.BranchTip(l.Target)
		if terr == nil && target != "" {
			return "pair:" + tip + ":" + target, true
		}
	}
	return tip, false
}

// verdictlessChecks names the required checks that have no VERDICT for
// a landing's branch tip — no run at all, or runs that were only ever
// cancelled. A failed run is a verdict and is deliberately not in this
// set: opening a landing must not quietly re-run over a failure
// somebody reported, which would hide it behind a fresh attempt.
//
// This is the set that has no way forward on its own. Nothing fires a
// run when a landing opens (ticket 49323c33): the only two callers of
// a run-starter are the fold loop, which has already been past this
// check-in, and the manual re-run button, which a reader has to know
// to press.
func (d Deps) verdictlessChecks(repoName string, l index.LandingSummary) []string {
	if d.Checks == nil || d.CI == nil {
		return nil
	}
	required := d.Checks.RequiredChecks(repoName, l.Target)
	if len(required) == 0 {
		return nil
	}
	subject, premerge := d.ciSubject(repoName, l)
	if subject == "" {
		return nil
	}
	var runs []CIRun
	var err error
	if premerge {
		runs, err = d.CI.CIRunsForPair(repoName, subject)
	} else {
		runs, err = d.CI.CIRunsFor(repoName, subject)
	}
	if err != nil {
		return nil
	}
	// An external check reporting anything at all is a verdict too; a
	// forge that fires its own pipeline over one would be answering a
	// question somebody else is already answering.
	var reported []string
	if d.External != nil {
		if ecs, eerr := d.External.ExternalChecks(repoName, subject); eerr == nil {
			for _, ec := range ecs {
				reported = append(reported, ec.Context)
			}
		}
	}
	return withoutVerdict(required, runs, reported)
}

// withoutVerdict is verdictlessChecks' judgement, separated from the
// index and the store it has to reach through to collect its inputs:
// which required checks nothing has reported on.
//
// The one rule worth stating: a CANCELLED run is not a verdict, and a
// FAILED one is. A landing opened over a stopped run has nothing to
// wait for and nothing to read; a landing opened over a failure has an
// answer, and re-running on its own would bury it.
func withoutVerdict(required []string, runs []CIRun, reported []string) []string {
	answered := map[string]bool{}
	for _, c := range reported {
		answered[c] = true
	}
	for _, r := range runs {
		if r.Status == "cancelled" || r.Status == "canceled" {
			continue
		}
		for _, name := range required {
			if CheckNameMatches(name, r.Workflow, r.Pipeline) {
				answered[name] = true
			}
		}
	}
	var out []string
	for _, name := range required {
		if !answered[name] {
			out = append(out, name)
		}
	}
	return out
}

// unmetChecks names the required checks that are not green for a
// landing's branch tip: a check with no run reads waiting and blocks,
// exactly like GitHub's required contexts. A check is matched by the
// pipeline path or the workflow name a run carries.
func (d Deps) unmetChecks(repoName string, l index.LandingSummary) []string {
	if d.Checks == nil {
		return nil
	}
	required := d.Checks.RequiredChecks(repoName, l.Target)
	if len(required) == 0 {
		return nil
	}
	green := map[string]bool{}
	if subject, premerge := d.ciSubject(repoName, l); subject != "" {
		if d.CI != nil {
			var runs []CIRun
			var err error
			if premerge {
				runs, err = d.CI.CIRunsForPair(repoName, subject)
			} else {
				runs, err = d.CI.CIRunsFor(repoName, subject)
			}
			if err == nil {
				for _, r := range runs {
					if passedStatus(r.Status) {
						// Every name this run answers to, through the
						// one rule CheckNameMatches states.
						for _, name := range required {
							if CheckNameMatches(name, r.Workflow, r.Pipeline) {
								green[name] = true
							}
						}
					}
				}
			}
		}
		if d.External != nil {
			if ecs, err := d.External.ExternalChecks(repoName, subject); err == nil {
				for _, ec := range ecs {
					if ec.State == "success" {
						green[ec.Context] = true
					}
				}
			}
		}
	}
	var unmet []string
	for _, name := range required {
		if !green[name] {
			unmet = append(unmet, name)
		}
	}
	return unmet
}

// passedStatus is the engine's vocabulary for success, matched the way
// internal/ci reads it.
// CheckNameMatches reports whether a required check's name names this
// run. EXPORTED because the landing page asks the same question and
// must not answer it differently.
//
// It did. unmetChecks gained stem matching when required-check names
// stopped being the workflow's; internal/web's MissingChecks kept
// matching only the workflow name and the full pipeline path, and
// pipelineStem was unexported so it could not have shared the rule
// even if somebody had thought to. The result was one panel
// contradicting itself: the accept was offered because the check was
// green, and the same panel said "Waiting for 1 required check … no
// run yet" about the check that had just passed.
//
// Three ways a name can match, all three deliberate: the workflow's
// registered name (an engine detail, but what a check configured
// against it says), the pipeline's full path, and the pipeline's stem —
// the stable human half, and what a person types.
func CheckNameMatches(required, workflow, pipeline string) bool {
	if required == "" {
		return false
	}
	return required == workflow || required == pipeline ||
		required == pipelineStem(pipeline)
}

// pipelineStem is a pipeline path reduced to the name a person uses for
// it: "ci/quarry.json" is "quarry". Empty in, empty out.
func pipelineStem(p string) string {
	if p == "" {
		return ""
	}
	base := path.Base(p)
	if i := strings.LastIndexByte(base, '.'); i > 0 {
		base = base[:i]
	}
	return base
}

func passedStatus(s string) bool {
	switch s {
	case "completed", "succeeded", "success":
		return true
	}
	return false
}

// mayOverride says who may accept over a red check: a branch owner
// (RFC 0007) or a holder of a.
func (d Deps) mayOverride(ctx context.Context, repo, branch string) bool {
	if ops.CapsOf(ctx).HasAny("a") {
		return true
	}
	protected, owners := d.protectionOf(repo, branch)
	if !protected {
		return false
	}
	user := ops.User(ctx)
	for _, o := range owners {
		if o == user {
			return true
		}
	}
	return false
}

// reporterTokenList shapes the settings listing.
func (d Deps) reporterTokenList(repo string) (ciTokenListOut, error) {
	ts, err := d.External.ReporterTokens(repo)
	if err != nil {
		return ciTokenListOut{}, err
	}
	out := ciTokenListOut{Tokens: []ciTokenRow{}}
	for _, t := range ts {
		out.Tokens = append(out.Tokens, ciTokenRow{Hash: t.Hash, Label: t.Label, Created: t.Created, Used: t.Used})
	}
	out.Count = len(out.Tokens)
	return out, nil
}

func (d Deps) protectionOf(repo, branch string) (bool, []string) {
	if d.Protect == nil || branch == "" {
		return false, nil
	}
	rules, err := d.Protect.Protections(repo)
	if err != nil {
		return false, nil
	}
	seen := map[string]bool{}
	var owners []string
	protected := false
	for _, p := range rules {
		if ok, _ := path.Match(p.Branch, branch); !ok && p.Branch != branch {
			continue
		}
		protected = true
		for _, o := range p.Owners {
			if !seen[o] {
				seen[o] = true
				owners = append(owners, o)
			}
		}
	}
	sort.Strings(owners)
	return protected, owners
}

type landingGetOut struct {
	Landing index.LandingSummary `json:"landing"`
	// Checks is the branch tip's runs, one row per pipeline, for the
	// record page's checks panel (RFC 0009). Required names the checks
	// the target branch demands; Unmet the required ones not green —
	// empty Unmet is what lets the accept proceed. MayOverride says the
	// CALLER may accept over a red set, with a stated reason.
	Checks   []CIRun  `json:"checks"`
	Required []string `json:"required"`
	// NeverRan is the subset of Required that has NO verdict at all for
	// this subject — as opposed to one that ran and was not green.
	//
	// Unmet already said "not satisfied", and a reader could not tell
	// which kind of not-satisfied they had: a check that failed, or one
	// that has never executed and, on a repository with no worker for
	// it, never will. jva pushed through such a gate for a day — the
	// rule rendered as "trunk joins after these checks pass", the runs
	// sat unstarted because nothing was attached to run them, and
	// nothing anywhere distinguished "not yet" from "never" (06c0690de7fd).
	//
	// The computation already existed: verdictlessChecks, used until now
	// only to decide whether OPENING a landing should trigger a run. It
	// was computed, acted on, and never shown to anybody.
	NeverRan []string `json:"neverRan,omitempty"`
	// ChecksHidden says the RUNS were withheld from this caller — they
	// hold no CI letter — as distinct from there being none. Without it
	// a reader who saw Required filled and Checks empty had exactly one
	// way to read the pair, "nothing has run", and the record page said
	// so in words, to the open web, about checks that had run and
	// passed (ticket 69603f2900ca). A withheld fact and an absent fact
	// must not arrive in the same shape.
	ChecksHidden bool `json:"checksHidden,omitempty"`
	// Premerge says the checks above are about the merged tree — the
	// pair of tips — not the branch tip (RFC 0009 §premerge).
	Premerge bool `json:"premerge"`
	// Behind counts the target's check-ins the branch has not seen;
	// zero means updating from the target would do nothing.
	Behind      int      `json:"behind"`
	Unmet       []string `json:"unmet"`
	MayOverride bool     `json:"mayOverride"`
	// AutoBy names who armed auto-land, empty when nobody has: the
	// record accepts itself once Unmet empties (RFC 0009 §verdicts).
	AutoBy string `json:"autoBy,omitempty"`
	// AutoWaiting is what the last sweep ran into, so an armed record
	// says why it has not fired instead of looking idle beside a green
	// tick. Empty when no sweep has reported anything yet, which for a
	// fresh arm means "it has not tried".
	AutoWaiting string `json:"autoWaiting,omitempty"`
	// AutoWaitingAt is when that reason was recorded. The AGE is the
	// diagnostic, not the reason: a wait from nineteen seconds ago is
	// the sweep working, and the same words from four minutes ago are
	// an arm that is stuck. Without it a reader cannot tell those
	// apart, which is the question that sent somebody to the log.
	AutoWaitingAt string `json:"autoWaitingAt,omitempty"`
	// Posts is the record's discussion — the same thread forum.get
	// returns, so a landing reads as the forum thread it is.
	Posts []landingPost `json:"posts"`
	// Attachments are the files on the RECORD — the thread root, which
	// is the landing's own id. A landing is a forum thread, so an
	// upload targeting its id was already stored and already listed by
	// attachment.list; it was the one object whose own op never
	// returned them, so nothing rendered them and a screenshot put on
	// a landing was reachable only through the API.
	Attachments []index.Attachment `json:"attachments,omitempty"`
	// ChatRefs is the WINDOW-BOUNDED count of times chat named this
	// artifact (RFC 0011 §14). Nil when nothing has, or when the forge
	// keeps no chat refs. Deliberately not folded into Mentions: those
	// are durable index facts and these expire with the channel, and a
	// reader who cannot tell them apart reads an expiry as data loss.
	ChatRefs chatRefRow `json:"chatRefs"`

	// CanAccept says the CALLER may settle this record: it writes a
	// merge check-in, so it needs `i`. Reported rather than left to the
	// page to guess, so the API and the UI cannot disagree about who
	// may decide.
	CanAccept bool `json:"canAccept"`
	// Mergeable is read from the STORE (zit#131): clean, up-to-date, or
	// conflict. Empty when the store could not be asked — which must
	// never be read as clean, because the whole point of asking is to
	// avoid learning the answer by attempting the merge.
	Mergeable string   `json:"mergeable,omitempty"`
	Conflicts []string `json:"conflicts,omitempty"`
	// Merge is the check-in this landing produced; its Checkin is empty
	// while the record is open or was rejected. Resolved from the DAG
	// rather than from settled_by, which names the reply that decided
	// the state and not the commit.
	Merge index.LandingMerge `json:"merge"`
	// MergeCI is the verdict on the check-in this landing PRODUCED, and
	// MergeCIRun the run behind it. Empty while the record is open,
	// because there is nothing merged to judge.
	MergeCI    string `json:"mergeCi,omitempty"`
	MergeCIRun string `json:"mergeCiRun,omitempty"`
	// Stack is the chain this landing is part of, bottom-up, for the
	// record page's map (RFC 0006). Empty for a standalone landing.
	Stack index.Strata `json:"stack"`
	// MayLand says the caller may write the merge this accept makes:
	// CanAccept minus RFC 0007. Reported separately so the page can
	// show a disabled control WITH the reason rather than no control.
	MayLand bool `json:"mayLand"`
	// MayLandReason says WHY MayLand is false when it is, in the words
	// landing.accept would refuse with. One producer for both (RFC
	// 0006): the page and the accept used to disagree — canAccept
	// true, mergeable "stacked", and then a refusal carrying zit's
	// command line — because "can this land now" was computed in three
	// places and none of them was the accept (e85bd39d).
	MayLandReason string   `json:"mayLandReason,omitempty"`
	Protected     bool     `json:"protected"`
	Owners        []string `json:"owners"`
	// Commits is the branch's own check-ins in ANCESTRY order. Resolved
	// here rather than through the timeline, which orders by date and
	// therefore by hash when two land in the same second.
	Commits []index.BranchCommit `json:"commits"`
}

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"`
	// Mentions are the landings and threads that named this ticket. The
	// references were always indexed; nothing asked the ticket side.
	Mentions []index.MentionRef `json:"mentions,omitempty"`
	// ChatRefs is the WINDOW-BOUNDED count of times chat named this
	// artifact (RFC 0011 §14). Nil when nothing has, or when the forge
	// keeps no chat refs. Deliberately not folded into Mentions: those
	// are durable index facts and these expire with the channel, and a
	// reader who cannot tell them apart reads an expiry as data loss.
	ChatRefs chatRefRow `json:"chatRefs"`
	// Local marks a ticket served from the local tier
	// (docs/local-tickets.md). PromotedTo, alone in an otherwise empty
	// answer, is the redirect a promoted local id leaves behind.
	Local      bool   `json:"local,omitempty"`
	PromotedTo string `json:"promotedTo,omitempty"`
}

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
	// scope is what a CROSS-repository reference token needs (refs.go):
	// the reader and the seams that say what they may see. nil is the
	// ordinary case — a render path with no request in hand resolves
	// only tokens naming its own repository.
	scope *refScope
	// attachTarget is the object this body belongs to — a ticket id, a wiki
	// page NAME, a technote event id, a forum post hash — as the A card
	// spells it. It is what makes `attach:NAME` unambiguous: a filename
	// is only a reference at all once you know which object's file list
	// to read it against. Empty on every render path that has no single
	// object behind it, and then attach: references resolve to nothing.
	// Spelled attachTarget and not target because resolver already has a
	// method by that name, for the unrelated business of where a
	// cross-repository REFERENCE points.
	attachTarget string
	// memo remembers what canonicalTarget already answered for this
	// resolver, so the pass that renders a message's links and the pass
	// that keys its backlink rows ask the index once between them. A
	// nil memo is the ordinary literal: it just asks.
	memo map[string]string
}

// docResolver renders a file from the tree, where a relative target is a
// sibling document. It resolves against the DOCUMENT's own directory,
// never the page's URL: the same file is rendered on the repository home
// page and under a tree listing too, and those URLs sit nowhere near the
// tree path. SQLite's README asks for doc/compile-for-unix.md; rendered
// at /r/sqlite a browser resolved that to /r/doc/compile-for-unix.md and
// got a 404, with the document itself at /r/sqlite/doc/trunk/doc/… all
// along.
type docResolver struct {
	resolver
	// version is the check-in the document was read at, and dir its
	// directory within that tree ("" at the root). Both empty means the
	// caller cannot say where the document sits, and a relative target
	// is then left exactly as the author wrote it.
	version string
	dir     string
}

// DocRelative maps a target relative to the current document onto the
// doc route. Query and fragment ride along, so ./x.md#section keeps its
// deep link.
func (d docResolver) DocRelative(target string) string {
	if target == "" || d.version == "" {
		return ""
	}
	body, tail := target, ""
	if i := strings.IndexAny(target, "#?"); i >= 0 {
		body, tail = target[:i], target[i:]
	}
	if body == "" {
		return ""
	}
	clean := pathpkg.Join(d.dir, body)
	// A target that climbs above the tree root names nothing this
	// repository can serve; leaving it literal beats inventing a path.
	if clean == ".." || strings.HasPrefix(clean, "../") {
		return ""
	}
	return "/r/" + d.repo + "/doc/" + d.version + "/" + clean + tail
}

// fossilArchiveVersion digs the version out of a /tarball or /zip URL:
// fossil spells it as the first path segment, or as uuid=/r= in the
// query. Empty when the link names no version at all.
func fossilArchiveVersion(rest string) string {
	// fossilRoute collapses the `/` and the `?` after the verb into one
	// separator, so /tarball?uuid=X arrives as a bare "uuid=X" with no
	// question mark left to cut on — the same wrinkle tktview handles.
	head, query, _ := strings.Cut(rest, "?")
	if query == "" && strings.Contains(head, "=") {
		head, query = "", head
	}
	if v, err := url.ParseQuery(query); err == nil {
		for _, k := range []string{"uuid", "r", "name"} {
			if got := v.Get(k); got != "" {
				return got
			}
		}
	}
	// /tarball/<version>/sqlite.tar.gz — the version is the first
	// segment, but /tarball/sqlite.tar.gz has none at all.
	if seg, _, ok := strings.Cut(head, "/"); ok && seg != "" &&
		!strings.Contains(seg, ".") {
		return seg
	}
	return ""
}

// docDirOf is the directory a tree path sits in, as DocRelative wants it.
// Named rather than inlined because the doc route shadows `path` with the
// document's own path and cannot reach the package there.
func docDirOf(p string) string { return pathpkg.Dir(p) }

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

// UserMark hands markup the identity glyph for a mentioned account, so an
// @mention in prose carries the same mark as the same person's byline.
func (r resolver) UserMark(name string) string { return idn.Mark(name) }

func (d docResolver) UserMark(name string) string { return idn.Mark(name) }

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 name this repository can place
// links to their person page, in their hue.
//
// TWO ways to be placeable, because there are two kinds of name here
// and dropping either loses something real:
//
//   - an ACCOUNT this forge knows who holds some capability on the
//     repository. This is mentionKnown — the very predicate that
//     decides whether naming somebody TELLS them.
//   - a CONTRIBUTOR on this repository's check-ins. Often not an
//     account at all (an imported fossil login, a name somebody's
//     client wrote), and their mentions have always rendered.
//
// It used to be the contributor fold ALONE, which meant you had to have
// committed code before your name would render as a name. dmestas found
// it in a forum thread: @dmestas notified him and rendered as plain
// text, because the notify side asked mentionKnown and this side asked
// the fold. One @name answering two different questions is the bug; the
// union is the fix, and it only ever widens.
func (r resolver) ResolveUser(name string) (string, int, bool) {
	known := r.scope.mentionable(r.repo, name)
	if !known && (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 { return idn.Hue(name) }

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

// ResolveAttachment resolves `attach:NAME` against this body's OWN
// object (RFC 0016 §Bodies). Only live attachments resolve, because the
// fold's answer is the same one the download route is gated on — a file
// taken down stops being a link in prose at the same moment it stops
// being reachable by URL.
//
// An image takes the inline route, which sniffs the bytes before it
// serves them; a link takes the download route, which serves nothing
// inline at all. The stored mimetype decides neither.
func (r resolver) ResolveAttachment(name string, image bool) string {
	if r.ix == nil || r.attachTarget == "" || name == "" {
		return ""
	}
	live, err := r.ix.AttachmentsFor(r.attachTarget)
	if err != nil {
		return ""
	}
	for _, a := range live {
		if a.Name != name {
			continue
		}
		u := "/r/" + r.repo + "/attachment/" + url.PathEscape(a.Src)
		if image {
			u += "/view"
		}
		return u
	}
	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]+)(?:[/?](.*))?$`)

// hexTail matches the artifact-hash tail of a fossil route. Prefixes are
// legal and common in comments, so this is not pinned to 40 or 64.
var hexTail = regexp.MustCompile(`^[0-9a-fA-F]{6,64}$`)

// FossilRoute maps the fossil site-relative URLs that need NO index
// lookup onto quarry routes. Exported because check-in comments carry
// the same links wiki pages do — [/md_rules|Markdown],
// [/wiki?name=branch/x|limitations] — and rendered them as literal
// markup because the comment renderer knew only hashes and http URLs.
// One table, two callers, rather than a third copy that drifts.
//
// Empty means quarry has no equivalent, and the caller shows the label
// as text rather than linking to a 404.
func FossilRoute(repo, p string) string {
	m := fossilRoute.FindStringSubmatch(p)
	if m == nil {
		return ""
	}
	verb, rest := m[1], m[2]
	base := "/r/" + url.PathEscape(repo)
	switch verb {
	case "timeline":
		return base + "/timeline"
	case "info", "artifact", "whatis":
		// The SAME destination a bare [hash] gets. quarry has had the
		// /info route all along and used it for bare hashes, but not
		// for the explicit spelling fossil's own comments use 21 times
		// across the corpus — so those degraded to plain text.
		//
		// Only a hex tail resolves. fossil also accepts a timestamp
		// here ([/info/2020-06-02T17:38z]), which names nothing quarry
		// can look up, and that keeps its documented fate: the label.
		if h, _, _ := strings.Cut(rest, "?"); hexTail.MatchString(h) {
			return base + "/info/" + url.PathEscape(h)
		}
	case "tktview":
		// /tktview/<id> and /tktview?name=<id> both name one ticket.
		// fossilRoute collapses the `/` and the `?` into one separator,
		// so which spelling arrived cannot be read off `rest` — try it
		// as a path tail first, then as a query.
		h, _, _ := strings.Cut(rest, "?")
		if !hexTail.MatchString(h) {
			if v, err := url.ParseQuery(rest); err == nil {
				h = v.Get("name")
			}
		}
		if hexTail.MatchString(h) {
			return base + "/ticket/" + url.PathEscape(h)
		}
	case "forum", "forumpost", "forumthread":
		// The forum index, not the thread. /forumthread/<hash> does name
		// one, and quarry has a page for it — but an imported comment
		// routinely cites a thread that lives on the ORIGIN server and
		// was never cloned, and that lands on a 404. Verified: the hash
		// in fossil's own markdown-footnotes comment resolves to
		// nothing in a clone of fossil.
		return base + "/forum"
	case "technote", "event":
		return base + "/technote"
	case "doc":
		if ver, path, ok := strings.Cut(rest, "/"); ok && path != "" {
			return base + "/doc/" + ver + "/" + path
		}
	case "dir":
		// /dir?ci=V&name=D — a directory listing, which quarry serves
		// as the tree. libfossil's wiki alone carries 211 of these.
		// fossil defaults the check-in to the tip when ci is absent.
		v, err := url.ParseQuery(rest)
		if err != nil {
			return ""
		}
		ci := v.Get("ci")
		if ci == "" {
			ci = "trunk"
		}
		u := base + "/tree/" + url.PathEscape(ci)
		if name := strings.Trim(v.Get("name"), "/"); name != "" {
			u += "/" + name
		}
		return u
	case "finfo":
		// /finfo?name=F[&ci=V] — one file's history, the commonest
		// fossil verb in imported content (libfossil's wiki alone
		// carries ~500). fossil defaults the check-in to the tip.
		v, err := url.ParseQuery(rest)
		if err != nil {
			return ""
		}
		name := strings.Trim(v.Get("name"), "/")
		if name == "" {
			return ""
		}
		ci := v.Get("ci")
		if ci == "" {
			ci = "trunk"
		}
		return base + "/history/" + url.PathEscape(ci) + "/" + name
	case "brlist":
		return base + "/branch"
	case "tarball", "zip":
		// /tarball/<version>/name.tar.gz, or the version in uuid=/r=.
		// Only a NAMED version maps: quarry's archive route has no
		// "whatever the tip is" spelling, and guessing trunk would hand
		// a reader a different tree than the link meant.
		if ver := fossilArchiveVersion(rest); ver != "" {
			return base + "/archive/" + url.PathEscape(ver)
		}
	case "wiki":
		// /wiki?name=Page. Linked WITHOUT checking the page exists,
		// which is the one place this diverges from the resolver: that
		// one drops an unresolvable name, but it has an index to ask
		// and this does not. Fossil links these unconditionally too,
		// and a comment naming a page that was never written is rarer
		// than one naming a page that was.
		if v, err := url.ParseQuery(rest); err == nil {
			if n := v.Get("name"); n != "" {
				name, frag, _ := strings.Cut(n, "#")
				u := base + "/wiki/" + url.PathEscape(name)
				if frag != "" {
					u += "#" + frag
				}
				return u
			}
		}
	}
	return ""
}

// 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,
		// which quarry now RENDERS rather than only serving as bytes.
		// This pointed at the file route back when that was the only
		// thing there was, so fossil's own [/doc/trunk/www/inout.wiki]
		// links landed a reader in a source listing.
		if ver, path, ok := strings.Cut(rest, "/"); ok && path != "" {
			return base + "/doc/" + 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", "forum", "forumpost", "forumthread", "technote", "event",
		// tktview was in FossilRoute's table from the start but never
		// listed here, so a /tktview link in imported wiki or forum
		// content was dropped while the same link in a check-in comment
		// resolved. One table, two callers — and they had drifted.
		"tktview",
		// Pages quarry has had for a while and was still throwing away.
		"dir", "brlist", "tarball", "zip",
		// finfo was gated on zit: the PATH walk reconstructed a manifest
		// per check-in and took two minutes on the SQLite mirror, so
		// pointing ~500 imported links at it would have been worse than
		// dropping them. zit#188 fixed the walk and 0.3.15 made its index
		// durable across commits; quarry now builds that index off the
		// request path (finfowarm.go). Measured on this fleet: the page
		// went from timing out past 120s to 923ms.
		"finfo":
		return FossilRoute(r.repo, p)
	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
				}
			}
		}
	}
	// uv, leaves, vdiff, help, stat, md_rules, timewarps — real Fossil
	// pages quarry has no equivalent for. 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, and whether the forge is ready: after a restart, repositories register one by one and `ready` is false until the last one has, with `starting` naming those still to come.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/version",
		Run: func(ctx context.Context, _ versionIn) (versionOut, error) {
			out := versionOut{Version: d.Version, Zit: d.ZitVersion, Ready: true}
			if d.Pending != nil {
				if names := d.Pending(); len(names) > 0 {
					out.Ready, out.Starting = false, names
				}
			}
			return out, 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). `attach` lists deferred upload tickets to commit against the page; refusals come back in `attachErrors`.",
		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
			}
			if err := checkAttachList(in.Attach); 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":    d.link("/r/" + in.Repo + "/wiki/" + in.Name)},
				TxnID: "activity:wiki:" + hash,
			})
			return wikiSaveOut{Hash: hash, AttachErrors: d.attachTickets(ctx, repo, in.Repo, in.Name, in.Attach)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[exceptionClearIn, exceptionClearOut]{
		Name:  "fleet.exception.clear",
		Caps:  "s",
		Scope: ops.ScopeFleet,
		Doc: "Discard one error signature and its count. " +
			"The next occurrence is a first sighting.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/fleet/exceptions/clear",
		Run: func(ctx context.Context, in exceptionClearIn) (exceptionClearOut, error) {
			if in.Sig == "" {
				return exceptionClearOut{}, fmt.Errorf("name the signature to clear: %w", ops.ErrInvalid)
			}
			// The ring first and unconditionally: it is what the panel
			// reads, so this is the half that decides whether the
			// operator sees their click take effect.
			held := false
			if d.Errors != nil {
				held = d.Errors.Forget(in.Sig)
			}
			if d.ForgetError != nil {
				if err := d.ForgetError(in.Sig); err != nil {
					return exceptionClearOut{}, err
				}
			}
			// Clearing something already gone is not an error. Two
			// admins on the same panel, or one double-click, must both
			// end with the signature gone and nobody shown a failure.
			return exceptionClearOut{Cleared: held}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ticketNewIn, ticketNewOut]{
		Name: "ticket.new",
		// `n` — filing a ticket is an ordinary act — and WHERE it lands
		// is the repository's answer, not the caller's privilege.
		//
		// This took `a` for a day, to stop the API bypassing the local
		// tier the web form enforces. It worked and it was the wrong
		// shape: it blocked an API caller who had no other door, while
		// the real question — does THIS repository keep its tickets in
		// the code history — already had an answer in Settings >
		// Tickets. dmestas's call, and it is the better one: permanence
		// is a repository policy, so the API obeys the same setting the
		// web form does.
		//
		// A repository on the forge tier files through intake here; one
		// that has chosen the code history writes the artifact log. The
		// `fossil` flag overrides for a single ticket and takes the
		// admin letter, which is exactly what the web form's "record in
		// the fossil now" box already required.
		Caps: "n",
		Doc: "Open a new ticket. Where it lands follows the repository's own setting (docs/local-tickets.md): the forge's tier by default, the artifact log for a repository that keeps its tracker there. " +
			"`fossil` overrides for one ticket and needs the admin letter. `attach` lists deferred upload tickets to commit against it; refusals come back in `attachErrors`.",
		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
			}
			// Where does this repository keep its tickets? Its own
			// answer, unless this caller overrides for one ticket and
			// holds the letter that buys permanence.
			// A forge with no local tier keeps the old door: there is
			// no other place for the ticket to go, and refusing would
			// close the tracker on a deployment that never opted into
			// the tier at all. Same rule the web form already had.
			toFossil := d.Local == nil ||
				(d.TicketTier != nil && d.TicketTier(in.Repo) == "fossil")
			if in.Fossil {
				if d.CapsFor == nil || !d.CapsFor(ops.User(ctx), in.Repo).HasAny("a") {
					return ticketNewOut{}, fmt.Errorf(
						"recording a ticket in the artifact log takes the admin letter on %s: %w",
						in.Repo, ops.ErrForbidden)
				}
				toFossil = true
			}
			if !toFossil {
				// The forge's tier. Delegated to ticket.intake rather
				// than reimplemented, so the two doors cannot drift:
				// one body, one set of field rules, one notification.
				out, ierr := r.Invoke(ctx, "ticket.intake", ticketIntakeIn{
					Repo: in.Repo, Title: in.Title, Status: in.Status,
					Comment: in.Comment, Type: in.Type, Severity: in.Severity,
					Priority: in.Priority, Subsystem: in.Subsystem,
					FoundIn: in.FoundIn, Fields: in.Fields,
				})
				if ierr != nil {
					return ticketNewOut{}, ierr
				}
				res, _ := out.(ticketIntakeOut)
				// No Hash: nothing was written to the artifact log, and
				// saying so by omission is the honest answer.
				return ticketNewOut{ID: res.ID, AttachErrors: attachRefusedLocally(in.Attach)}, nil
			}
			if err := checkAttachList(in.Attach); 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,
				"foundin": in.FoundIn,
			} {
				if v != "" {
					fields[k] = v
				}
			}
			extra, err := extraTicketFields(authorableFields(repo), in.Fields)
			if err != nil {
				return ticketNewOut{}, err
			}
			for k, v := range extra {
				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":    d.link("/r/" + in.Repo + "/ticket/" + id)},
				TxnID: "activity:ticket-new:" + hash,
			})
			return ticketNewOut{ID: id, Hash: hash, AttachErrors: d.attachTickets(ctx, repo, in.Repo, id, in.Attach)}, 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. `attach` lists deferred upload tickets to commit against the ticket (alone, with no field, they are attached and nothing else is written); refusals come back in `attachErrors`.",
		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
			}
			if err := checkAttachList(in.Attach); 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 {
				if d.Local != nil {
					if lt, lok, lerr := d.Local.LocalTicketByPrefix(in.Repo, in.ID); lerr != nil {
						return ticketSetOut{}, lerr
					} else if lok && lt.PromotedTo == "" {
						return d.localTicketSet(ctx, in, lt)
					}
				}
				return ticketSetOut{}, fmt.Errorf("no unique ticket for %q: %w", in.ID, ops.ErrNotFound)
			}
			fields := ticketSetFields(in)
			// The tracker's own columns. Unlike the named inputs, an
			// EMPTY value here is a clear rather than "leave alone":
			// it is the only way to unset a field, and a form that
			// could set but never clear would trap a wrong assignee on
			// a ticket forever.
			extra, err := extraTicketFields(authorableFields(repo), in.Fields)
			if err != nil {
				return ticketSetOut{}, err
			}
			for k, v := range extra {
				fields[k] = v
			}
			if len(fields) == 0 {
				if len(in.Attach) == 0 {
					return ticketSetOut{}, fmt.Errorf("no fields to set: %w", ops.ErrInvalid)
				}
				// Files alone: nothing to write on the ticket, only
				// A cards to attach to it.
				return ticketSetOut{AttachErrors: d.attachTickets(ctx, repo, in.Repo, t.ID, in.Attach)}, nil
			}
			if vocab, _, _ := d.mergedTicketVocab(ctx, repo); vocab != nil {
				if err := refuseUnknownVocab(vocab, fields); err != nil {
					return ticketSetOut{}, err
				}
			}
			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":    d.link("/r/" + in.Repo + "/ticket/" + t.ID[:min(10, len(t.ID))])},
				TxnID: "activity:ticket-set:" + hash,
			})
			return ticketSetOut{Hash: hash, AttachErrors: d.attachTickets(ctx, repo, in.Repo, t.ID, in.Attach)}, 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() {
				// A companion is a store, not a project (RFC 0015): it
				// is listed on Fleet with its parent and nowhere here.
				if d.companionParent(name) != "" {
					continue
				}
				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
					}
				}
			}
			metas := map[string]RepoMeta{}
			if d.RepoMeta != nil {
				metas = d.RepoMeta()
			}
			now := time.Now()
			rows := make([]repoInfo, 0, len(names))
			// Still-registering repositories are listed as such, so a
			// front page during startup is honest rather than empty.
			if d.Pending != nil {
				for _, name := range d.Pending() {
					if !matches(name) || (in.Kind == "mirror") {
						continue
					}
					rows = append(rows, repoInfo{Name: name, Description: desc[name], Indexing: true, Starting: true})
				}
			}
			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],
					Forum: d.forumCompanion(name)}
				// Chat here: a plane runs and the setting is on. The
				// chat rail lists channels from this, so it is answered
				// per row rather than by a repo.get each.
				info.ChatSetting = true
				// The STORED flag, defaulting on the way the column
				// does: a repository with no meta row has not turned
				// mirroring off.
				stored := true
				if mt, ok := metas[name]; ok {
					info.ChatSetting = mt.Chat
					info.Topics = strings.Join(mt.Topics, " ")
					stored = mt.Mirrorable
				}
				// Mirrorable is the EFFECTIVE answer, the same question
				// repo.get answers: the stored flag AND not private.
				// Computed from the two bulk maps this loop already
				// holds rather than by asking Deps.Mirrorable per row,
				// which would be a query per repository.
				//
				// Never assigned, it reported the zero value: every
				// repository in the fleet listing said mirroring was
				// off, including the ones syncing fine, while
				// /r/{repo}/info said the opposite about the same flag.
				// The listing is what a person reads first, so the
				// forge looked like it had sync off everywhere.
				info.Mirrorable = stored && !info.Private
				info.Chat = d.Chat != nil && info.ChatSetting
				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 {
					// The row's two numbers, not the whole census: see
					// index.Summary. Neither depends on the caller's
					// privacy, so none is passed.
					if sm, err := ix.Summary(); err == nil {
						info.Checkins, info.Contributors = sm.Checkins, sm.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",
		Scope: ops.ScopeFleet,
		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 {
				// Enough for the whole journal: the ring is capped at
				// 500 signatures and the panel filters within what it
				// is given, so a smaller number here silently hides
				// faults from the filter as well as from the list.
				out.Errors = d.Errors.Recent(500)
				out.ErrorTotal, out.ErrorDistinct, _ = d.Errors.Stats()
				// The shape, attached to the rows that have one. A
				// signature with no recent occurrence keeps a nil rate,
				// which is the true answer: it is not happening.
				if d.ErrorRates != nil {
					if rates, rerr := d.ErrorRates(24, 24*time.Hour); rerr == nil {
						for i := range out.Errors {
							out.Errors[i].Rate = rates[out.Errors[i].Sig()]
						}
					}
				}
			}
			for _, name := range names {
				repo, ok := d.Repos.Get(name)
				if !ok || repo == nil {
					continue
				}
				row := fleetRepo{Name: name, Code: repo.Code, Parent: d.companionParent(name)}
				if d.Buckets != nil && row.Parent == "" {
					if b, ok, err := d.Buckets.Bucket(name); err == nil && ok {
						row.Bucket, row.BucketProbedAt, row.BucketErr = b.Bucket, b.ProbedAt, b.ProbeErr
					}
				}
				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[opsStatusIn, opsStatusOut]{
		Name:  "ops.status",
		Caps:  "s",
		Scope: ops.ScopeFleet,
		Doc:   "Operational analytics: slowest routes, recent renders, store subprocess load.",
		Kind:  ops.Query,
		Auth:  ops.Admin,
		Route: "GET /api/v1/ops",
		Run: func(ctx context.Context, in opsStatusIn) (opsStatusOut, error) {
			snap := obs.Default.Snapshot()
			out := opsStatusOut{
				LiveSubprocess: snap.LiveSubproc,
				PeakSubprocess: snap.PeakSubproc,
			}
			// The shape beside the numbers. Same rings, same window for
			// both, so the two lines on one page are comparable.
			now := time.Now()
			out.SubprocessSeries = obs.ConcurrencyBins(snap.Subprocess, now, obs.SeriesWindow, obs.Bins)
			out.RenderSeries = obs.CountBins(snap.RenderTimes(), now, obs.SeriesWindow, obs.Bins)
			out.SeriesWindowSeconds = int(obs.SeriesWindow.Seconds())
			out.Phase = fleetPhase(d)
			out.BudgetMs = obs.Budget.Milliseconds()
			out.Store = obs.Default.Health()
			// Filter BEFORE the limit, or a search would only ever look
			// inside the first page of an already-sorted table and read
			// as "no such route" for one that is simply slower down.
			find := strings.ToLower(in.Find)
			for _, p := range snap.Paths {
				if find != "" && !strings.Contains(strings.ToLower(p.Path), find) {
					continue
				}
				out.Paths = append(out.Paths, opsPath{
					Path: p.Path, Count: p.N,
					P50Ms: ms(p.P50), P95Ms: ms(p.P95), MaxMs: ms(p.Max),
				})
			}
			sortOpsPaths(out.Paths, in.Sort)
			if len(out.Paths) > in.Limit {
				out.Paths = out.Paths[:in.Limit]
			}
			for _, v := range snap.Renders {
				if len(out.Renders) >= in.Limit {
					break
				}
				out.Renders = append(out.Renders, opsRender{
					At: v.At.UTC().Format(time.RFC3339), Path: v.Path, URL: v.URL, Repo: v.Repo,
					ElapsedMs: ms(v.Elapsed), OpsMs: ms(v.Ops), ZitMs: ms(v.Zit),
					Status: v.Status,
				})
			}
			// Per-verb rollup rather than a raw list: "ls ×13, 276s
			// worst" is the sentence an operator needs, and the
			// individual invocations are noise beside it.
			// Where each verb's calls came from. The render ring is the
			// lookup for request-driven work: a render still in it
			// names its route, one that has aged out names nothing
			// rather than being guessed at.
			routeOf := map[string]string{}
			for _, r := range snap.Renders {
				routeOf[r.ID] = r.Path
			}
			attrib := map[string]obs.Attribution{}
			for _, a := range obs.AttributeVerbs(snap.Subprocess,
				func(id string) string { return routeOf[id] }, 3) {
				attrib[a.Verb] = a
			}
			tallies := func(ts []obs.Tally) []opsTally {
				out := make([]opsTally, 0, len(ts))
				for _, t := range ts {
					out = append(out, opsTally{Name: t.Name, Count: t.Count})
				}
				return out
			}
			byVerb := map[string]*opsVerb{}
			for _, v := range snap.Subprocess {
				k := v.Verb
				e := byVerb[k]
				if e == nil {
					e = &opsVerb{Verb: k}
					byVerb[k] = e
				}
				e.Count++
				e.TotalMs += ms(v.Duration)
				if d := ms(v.Duration); d > e.MaxMs {
					e.MaxMs = d
				}
				if a, ok := attrib[k]; ok {
					e.Repos, e.Causes = tallies(a.Repos), tallies(a.Causes)
				}
			}
			for _, e := range byVerb {
				out.Verbs = append(out.Verbs, *e)
			}
			sort.Slice(out.Verbs, func(i, j int) bool {
				if out.Verbs[i].TotalMs != out.Verbs[j].TotalMs {
					return out.Verbs[i].TotalMs > out.Verbs[j].TotalMs
				}
				return out.Verbs[i].Verb < out.Verbs[j].Verb
			})
			if d.Errors != nil {
				out.ErrorTotal, _, _ = d.Errors.Stats()
			}
			out.Lanes = []zitcli.Lane{zitcli.StoreLane(), archiveLane()}
			// Every lane the composition root owns, not just the two
			// this package can see. The ingest lane was missing and it
			// is the one that sheds: tcl was refused 7.8 times an hour
			// for a week while this panel — the panel for exactly that
			// question — listed two lanes that were both idle.
			if d.Lanes != nil {
				out.Lanes = append(out.Lanes, d.Lanes()...)
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[opsRouteIn, opsRouteOut]{
		Name:  "ops.route",
		Caps:  "s",
		Scope: ops.ScopeFleet,
		Doc:   "One route's recent renders, split by repository, with the store calls each one made.",
		Kind:  ops.Query,
		Auth:  ops.Admin,
		Route: "GET /api/v1/ops/route",
		Run: func(ctx context.Context, in opsRouteIn) (opsRouteOut, error) {
			d := obs.Default.Route(in.Path, in.Limit)
			out := opsRouteOut{Path: d.Path}
			byRepo := map[string][]int64{}
			for _, rc := range d.Renders {
				row := opsRouteRender{
					At:   rc.Render.At.UTC().Format(time.RFC3339),
					URL:  rc.Render.URL,
					Repo: rc.Render.Repo, ElapsedMs: ms(rc.Render.Elapsed),
					OpsMs: ms(rc.Render.Ops), ZitMs: ms(rc.Render.Zit),
					Status: rc.Render.Status,
				}
				for _, c := range rc.Calls {
					row.Calls = append(row.Calls, opsRouteCall{
						Verb: c.Verb, Repo: c.Repo, DurationMs: ms(c.Duration)})
					row.CallMs += ms(c.Duration)
				}
				out.Renders = append(out.Renders, row)
				byRepo[rc.Render.Repo] = append(byRepo[rc.Render.Repo], ms(rc.Render.Elapsed))
			}
			for repo, xs := range byRepo {
				sort.Slice(xs, func(i, j int) bool { return xs[i] < xs[j] })
				e := opsRouteRepo{Repo: repo, Count: len(xs), MaxMs: xs[len(xs)-1]}
				e.P50Ms = xs[(len(xs)-1)/2]
				out.ByRepo = append(out.ByRepo, e)
			}
			sort.Slice(out.ByRepo, func(i, j int) bool {
				if out.ByRepo[i].MaxMs != out.ByRepo[j].MaxMs {
					return out.ByRepo[i].MaxMs > out.ByRepo[j].MaxMs
				}
				return out.ByRepo[i].Repo < out.ByRepo[j].Repo
			})
			// History answers the question the snapshot above cannot:
			// whether this route is getting worse. A failure here is
			// not the route's problem, and the panel is still useful
			// without it.
			out.TrendDays = in.Days
			if t, terr := obs.Default.Trend(in.Path, in.Days); terr == nil {
				out.Trend = t
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[opsHistoryIn, opsHistoryOut]{
		Name:  "ops.history",
		Caps:  "s",
		Scope: ops.ScopeFleet,
		Doc:   "Renders read back from the durable analytics store, newest or slowest first.",
		Kind:  ops.Query,
		Auth:  ops.Admin,
		Route: "GET /api/v1/ops/history",
		Run: func(ctx context.Context, in opsHistoryIn) (opsHistoryOut, error) {
			out := opsHistoryOut{Find: in.Find, Repo: in.Repo, Sort: in.Sort, Days: in.Days}
			out.Store = obs.Default.Health()
			rows, err := obs.Default.History(obs.HistoryQuery{
				Find: in.Find, Repo: in.Repo, Sort: in.Sort,
				Days: in.Days, Limit: in.Limit,
			})
			if err != nil {
				// A store that cannot be read is a fact about the
				// store, not a failed page: Health already carries it
				// and the page says so rather than 500ing.
				return out, nil
			}
			out.Rows = rows
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[fleetSyncIn, fleetSyncOut]{
		Name:  "fleet.sync",
		Caps:  "s",
		Scope: ops.ScopeFleet,
		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",
		// The FORGE's mirrors, not the caller's: this declared
		// ScopeCaller, which made the refusal read as a repository
		// sentence and hid that the letter is evaluated against the
		// fleet (7ddd1768). The gate is unchanged — a site admin holds
		// it, nobody else does — and the scope now says what it is.
		Scope: ops.ScopeFleet,
		Doc:   "List configured upstream mirrors and their recent history.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		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)
				if b, ok := d.bridgeOf(ms[i].Repo); ok {
					ms[i].Bridged, ms[i].ChatBridge = true, b
				}
			}
			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",
		Scope: ops.ScopeRepo,
		Doc:   "Configure a store's upstream and pull interval.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		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",
		Scope: ops.ScopeRepo,
		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.Token,
		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",
		Scope: ops.ScopeRepo,
		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. It is the mirror's own repository and the owner's " +
			"fork to reconcile, so this is the repository's business like the rest of mirror.* — the `a` letter, " +
			"not site admin.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		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 := registerLandingRetarget(r, d); err != nil {
		return err
	}
	if err := d.registerForgeBucket(r); err != nil {
		return err
	}
	if err := d.registerAvatars(r); err != nil {
		return err
	}
	if err := registerForgeActLog(r, d); err != nil {
		return err
	}
	if err := registerRepoClaim(r, d); err != nil {
		return err
	}
	if err := registerChatBridge(r, d); err != nil {
		return err
	}
	if err := registerBuckets(r, d); err != nil {
		return err
	}
	if err := registerAttachWrite(r, d); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[mirrorRunIn, mirrorRunOut]{
		Name:  "mirror.run",
		Caps:  "a",
		Scope: ops.ScopeRepo,
		Doc:   "Pull from upstream now — one store, or every configured mirror when `repo` is empty.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		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",
		Scope: ops.ScopeFleet,
		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[accountExplainIn, accountExplainOut]{
		Name: "account.explain",
		// No cap at the surface, because the answer depends on WHOSE
		// letters are being asked for and the router cannot know that.
		// Enforced in the body instead: your own, always; anybody
		// else's, with the admin letter.
		//
		// It took `a` for everyone, so a person could not be told what
		// they themselves may do on a repository — the forge's one
		// surface whose whole job is to answer that question refused
		// the person asking about themselves. jva met it from every
		// vantage, and it is the root of ticket f44e0c66: a reader
		// cannot act on a refusal when nothing will tell them what they
		// hold.
		//
		// Reading your own letters discloses nothing: they govern what
		// you can already do, and you can discover every one of them by
		// trying.
		Doc: "Read an account's capability string on a repository as a role, the abilities that differ from it, and a sentence of what the person can do there (RFC 0008). " +
			"Your own needs nothing; anybody else's needs the admin letter on that repository.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/accounts/explain",
		Run: func(ctx context.Context, in accountExplainIn) (accountExplainOut, error) {
			ex, ok := d.Accounts.(CapsExplainer)
			if !ok {
				return accountExplainOut{}, errNoAccounts()
			}
			if who := ops.User(ctx); who != in.Name {
				if d.CapsFor == nil || !d.CapsFor(who, in.Repo).HasAny("a") {
					return accountExplainOut{}, fmt.Errorf(
						"%s may read their own capabilities; reading %s's on %s needs the admin letter there: %w",
						who, in.Name, in.Repo, ops.ErrForbidden)
				}
			}
			stored, eff, err := ex.CapsAt(in.Name, in.Repo)
			if err != nil {
				return accountExplainOut{}, err
			}
			rd := oltp.Infer(stored)
			out := accountExplainOut{Name: in.Name, Repo: in.Repo, Caps: stored, Effective: eff,
				Role: rd.Role, Exact: rd.Exact, Plus: []string{}, Minus: []string{},
				// Both halves from ONE rule. The sentence went through
				// Explain, which reads `a` as a wildcard because
				// HasAny does; the letter list went through Decode,
				// which reads `a` as naming only itself. So a
				// repository admin was told they could check in, above
				// a letter list with no i in it (8ea1e46d).
				Can: oltp.Explain(in.Name, oltp.CapSet(eff)), Grants: oltp.Decode(oltp.AsGated(eff))}
			if stored == "" {
				out.Role = "none"
			}
			out.Plus, out.Minus = abilitySentences(rd.Plus), abilitySentences(rd.Minus)
			if out.Grants == nil {
				out.Grants = []string{}
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[accountAddIn, accountAddOut]{
		Name:  "account.add",
		Caps:  "s",
		Scope: ops.ScopeFleet,
		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))
			}
			// A new account is a new notify subscriber (RFC 0011 §7) —
			// otherwise every trigger addressed to them reaches nobody
			// until an operator notices and types a curl command.
			name := in.Name
			d.provision(func(ctx context.Context) error {
				return d.Provision.EnsureSubscriber(ctx, name)
			})
			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",
		// Another account's password, not the caller's — so the subject
		// is the forge and not ScopeCaller, and the letter is gone
		// because Auth: ops.Admin already refuses everybody a
		// repository could not grant it to. The letter never fired:
		// the floor refuses first, which is why this read as an admin
		// route all along (7ddd1768).
		Scope: ops.ScopeFleet,
		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[mePasswdIn, struct{}]{
		Name: "me.passwd",
		Doc: "Change the signed-in account's own password. Verifies the current one through the same " +
			"verifier a login uses, refuses a new password the account store would refuse, and on " +
			"success ends every OTHER session of the account — the browser making the change stays " +
			"signed in — and tells the account, at its confirmed address, that its password changed.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/me/passwd",
		Run: func(ctx context.Context, in mePasswdIn) (struct{}, error) {
			who := ops.User(ctx)
			if who == "" {
				return struct{}{}, fmt.Errorf("changing your password needs a signed-in account: %w", ops.ErrInvalid)
			}
			aa, err := d.accountAuth()
			if err != nil {
				return struct{}{}, err
			}
			if !aa.VerifyPassword(who, in.Current) {
				return struct{}{}, fmt.Errorf("that is not your current password: %w", ops.ErrInvalid)
			}
			if err := aa.SetPasswordSelf(who, in.New, in.Keep); err != nil {
				return struct{}{}, fmt.Errorf("%w: %s", ops.ErrInvalid, scrub(err, in.New))
			}
			d.mailAccount(ctx, who, "Your quarry password changed",
				"Your quarry password was just changed. If you did not make this change, contact your forge's administrator.",
				"<p>Your quarry password was just changed. If you did not make this change, contact your forge's administrator.</p>")
			return struct{}{}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[struct{}, meSyncTokenOut]{
		Name: "me.sync.token",
		Doc: "Mint this account's push credential — the password `zit push` and `fossil push` prove themselves with — and return it the ONE time it is readable. " +
			"Minting again retires the previous one. The credential is deliberately not the account password: it is revocable on its own, it is useless for signing in, " +
			"and it covers every repository the account may check in to, including ones created after it was minted.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/me/sync-token",
		Run: func(ctx context.Context, _ struct{}) (meSyncTokenOut, error) {
			who := ops.User(ctx)
			if who == "" {
				return meSyncTokenOut{}, fmt.Errorf("minting a push credential needs a signed-in account: %w", ops.ErrInvalid)
			}
			if d.SyncTokens == nil {
				return meSyncTokenOut{}, fmt.Errorf("this forge does not gate pushes by account: %w", ops.ErrInvalid)
			}
			token, err := d.SyncTokens.MintSyncToken(who)
			if err != nil {
				return meSyncTokenOut{}, err
			}
			return meSyncTokenOut{Token: token, User: who}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[struct{}, struct{}]{
		Name:  "me.sync.token.revoke",
		Doc:   "Withdraw this account's push credential. Pushes stop at once, everywhere, because nothing caches it.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/me/sync-token/revoke",
		Run: func(ctx context.Context, _ struct{}) (struct{}, error) {
			who := ops.User(ctx)
			if who == "" {
				return struct{}{}, fmt.Errorf("withdrawing a push credential needs a signed-in account: %w", ops.ErrInvalid)
			}
			if d.SyncTokens == nil {
				return struct{}{}, fmt.Errorf("this forge does not gate pushes by account: %w", ops.ErrInvalid)
			}
			return struct{}{}, d.SyncTokens.RevokeSyncToken(who)
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[struct{}, meSyncTokenStateOut]{
		Name:  "me.sync.token.state",
		Doc:   "Whether this account holds a push credential and when it was minted — never its value, which is readable only at the moment it is made.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/me/sync-token",
		Run: func(ctx context.Context, _ struct{}) (meSyncTokenStateOut, error) {
			who := ops.User(ctx)
			if who == "" {
				return meSyncTokenStateOut{}, fmt.Errorf("reading your push credential needs a signed-in account: %w", ops.ErrInvalid)
			}
			if d.SyncTokens == nil {
				return meSyncTokenStateOut{}, nil
			}
			created, ok := d.SyncTokens.SyncTokenIssued(who)
			return meSyncTokenStateOut{Held: ok, Created: created, Available: true}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[meAccountIn, meAccountOut]{
		Name:  "me.account",
		Doc:   "The signed-in account's own identity: its login, whether it is a site admin, and when it was created.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/me",
		Run: func(ctx context.Context, in meAccountIn) (meAccountOut, error) {
			who := ops.User(ctx)
			if who == "" {
				return meAccountOut{}, fmt.Errorf("reading your own account needs a signed-in account: %w", ops.ErrInvalid)
			}
			if d.Accounts == nil {
				return meAccountOut{}, errNoAccounts()
			}
			accts, err := d.Accounts.Accounts()
			if err != nil {
				return meAccountOut{}, err
			}
			_, passwordAuth := d.Accounts.(AccountAuth)
			for _, a := range accts {
				if a.Name == who {
					out := meAccountOut{Name: a.Name, Admin: a.Admin, Created: a.Created,
						PasswordAuth: passwordAuth, Repos: []meRepo{}, Teams: []string{}}
					// What you may reach. Best-effort on purpose: a
					// forge with no teams, or a grant store that
					// stumbles, must still answer who you are rather
					// than fail the whole read.
					if grants, gerr := d.Accounts.GrantsFor(who); gerr == nil {
						// The stored string, not the collapsed role, so
						// a grant holding MORE than its nearest preset
						// name says so. accounts.explain's own seam,
						// for the same reason it needs it.
						ex, canExplain := d.Accounts.(CapsExplainer)
						for repo, role := range grants {
							// "" is the site-admin marker rather than a
							// grant; Admin above already says it.
							if repo == "" {
								continue
							}
							row := meRepo{Repo: repo, Role: role, Plus: []string{}, Minus: []string{}}
							if canExplain {
								if stored, _, cerr := ex.CapsAt(who, repo); cerr == nil {
									rd := oltp.Infer(stored)
									if stored == "" {
										rd.Role = "none"
									}
									row.Role, row.Exact = rd.Role, rd.Exact
									row.Plus, row.Minus = abilitySentences(rd.Plus), abilitySentences(rd.Minus)
								}
							}
							out.Repos = append(out.Repos, row)
						}
						sort.Slice(out.Repos, func(i, j int) bool {
							return out.Repos[i].Repo < out.Repos[j].Repo
						})
					}
					if d.Teams != nil {
						if teams, terr := d.Teams.TeamsFor(who); terr == nil {
							for _, tm := range teams {
								out.Teams = append(out.Teams, tm.Name)
							}
							sort.Strings(out.Teams)
						}
					}
					return out, nil
				}
			}
			return meAccountOut{}, fmt.Errorf("no such account %q: %w", who, ops.ErrNotFound)
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[accountDeleteIn, accountNameOut]{
		Name:  "account.delete",
		Caps:  "s",
		Scope: ops.ScopeFleet,
		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:  "a",
		Scope: ops.ScopeRepo,
		Doc: "Give an account a role on one repository: reader, developer, maintainer or admin (RFC 0008; read and write are accepted as older spellings of the first two). The role's capability string replaces any string the account held there. " +
			"Who is on a repository is that repository's own business — the `a` letter, not site admin.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		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
			}
			warn := ""
			if g, ok := d.Accounts.(GrantGuard); ok {
				w, err := g.GuardGrant(author(ctx), in.Name, in.Repo, in.Role)
				if err != nil {
					return accountGrantOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
				}
				warn = w
			}
			// Two befores, and they are not the same fact. The ROLE is
			// what a person is told ("dan gave you maintainer"); the
			// capability STRING is what the vocabulary event describes,
			// because a role change and a hand-edited string are the
			// same transition to a subscriber. Both are captured here
			// because after the write both are gone.
			before := d.roleBefore(in.Name, in.Repo)
			beforeCaps := d.capsNow(in.Name, in.Repo)
			if err := d.Accounts.Grant(in.Name, in.Repo, in.Role); err != nil {
				return accountGrantOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
			}
			// RFC 0005's account.* kinds, raised here as well as at
			// account.caps. A role change must not reach a webhook when
			// it is made one way and vanish when it is made another.
			d.raiseAccountChange(ctx, in.Repo, in.Name, beforeCaps, d.capsNow(in.Name, in.Repo))
			name, repoName := in.Name, in.Repo
			d.provision(func(ctx context.Context) error {
				return d.Provision.SetTopicMember(ctx, repoName, name, true)
			})
			d.AccessChanged(ctx, author(ctx), in.Name, in.Repo, before, in.Role)
			return accountGrantOut{Name: in.Name, Repo: in.Repo, Role: in.Role, Warning: warn}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[accountRevokeIn, accountGrantOut]{
		Name:  "account.revoke",
		Caps:  "a",
		Scope: ops.ScopeRepo,
		Doc:   "Remove an account's grant on one repository — that repository's own business, the `a` letter.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/accounts/revoke",
		Run: func(ctx context.Context, in accountRevokeIn) (accountGrantOut, error) {
			if d.Accounts == nil {
				return accountGrantOut{}, errNoAccounts()
			}
			warn := ""
			if g, ok := d.Accounts.(GrantGuard); ok {
				w, err := g.GuardRevoke(author(ctx), in.Name, in.Repo)
				if err != nil {
					return accountGrantOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
				}
				warn = w
			}
			before := d.roleBefore(in.Name, in.Repo)
			beforeCaps := d.capsNow(in.Name, in.Repo)
			if err := d.Accounts.Revoke(in.Name, in.Repo); err != nil {
				return accountGrantOut{}, err
			}
			d.raiseAccountChange(ctx, in.Repo, in.Name, beforeCaps, "")
			name, repoName := in.Name, in.Repo
			d.provision(func(ctx context.Context) error {
				return d.Provision.SetTopicMember(ctx, repoName, name, false)
			})
			d.AccessChanged(ctx, author(ctx), in.Name, in.Repo, before, "")
			return accountGrantOut{Name: in.Name, Repo: in.Repo, Warning: warn}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[settingsListIn, settingsListOut]{
		Name:  "settings.list",
		Caps:  "a",
		Doc:   "List the fossil settings in force for a repository's store (RFC 0008 rung 8). On a store with no working tree the reply is supported=false with a note, until zit ships settings -R on bare stores (zit#153).",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/r/{repo}/settings/fossil",
		Run: func(ctx context.Context, in settingsListIn) (settingsListOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return settingsListOut{}, err
			}
			if repo.Zit == nil {
				return settingsListOut{Supported: false, Note: "this repository has no store to read settings from"}, nil
			}
			rows, err := repo.Zit.Settings(ctx)
			if errors.Is(err, zitcli.ErrStoreNoCheckout) {
				return settingsListOut{Supported: false, Note: storeNoCheckoutNote}, nil
			}
			if err != nil {
				return settingsListOut{}, err
			}
			out := settingsListOut{Supported: true}
			for _, st := range rows {
				out.Settings = append(out.Settings, settingRow{Name: st.Name, Value: st.Value, Versionable: st.Versionable})
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[settingsSetIn, settingsListOut]{
		Name:  "settings.set",
		Caps:  "a",
		Doc:   "Set one repository-tier fossil setting on a store, then return the refreshed list (RFC 0008 rung 8). supported=false until zit#153.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/settings/fossil",
		Run: func(ctx context.Context, in settingsSetIn) (settingsListOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return settingsListOut{}, err
			}
			if repo.Zit == nil {
				return settingsListOut{Supported: false, Note: "this repository has no store to write settings to"}, nil
			}
			err = repo.Zit.SetSetting(ctx, in.Name, in.Value)
			if errors.Is(err, zitcli.ErrStoreNoCheckout) {
				return settingsListOut{Supported: false, Note: storeNoCheckoutNote}, nil
			}
			if err != nil {
				return settingsListOut{}, err
			}
			rows, err := repo.Zit.Settings(ctx)
			if err != nil {
				return settingsListOut{}, err
			}
			out := settingsListOut{Supported: true}
			for _, st := range rows {
				out.Settings = append(out.Settings, settingRow{Name: st.Name, Value: st.Value, Versionable: st.Versionable})
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[configImportIn, settingsListOut]{
		Name:  "config.import",
		Caps:  "a",
		Doc:   "Apply a fossil configuration-area export file to a store — replace (import) or fold (merge), as the file's cards decide (RFC 0008 rung 8). supported=false until zit#153.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/config/import",
		Run: func(ctx context.Context, in configImportIn) (settingsListOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return settingsListOut{}, err
			}
			if repo.Zit == nil {
				return settingsListOut{Supported: false, Note: "this repository has no store to import into"}, nil
			}
			err = repo.Zit.ImportConfig(ctx, in.Merge, []byte(in.File))
			if errors.Is(err, zitcli.ErrStoreNoCheckout) {
				return settingsListOut{Supported: false, Note: storeNoCheckoutNote}, nil
			}
			if err != nil {
				return settingsListOut{}, err
			}
			return settingsListOut{Supported: true}, 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, Forum: d.forumCompanion(in.Repo)}
			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.Mirrorable != nil {
				info.Mirrorable = d.Mirrorable(in.Repo)
			} else {
				info.Mirrorable = true
			}
			if d.Visibility != nil {
				info.Visibility = d.Visibility(in.Repo)
			}
			info.ChatSetting = true
			if d.RepoMeta != nil {
				if meta, ok := d.RepoMeta()[in.Repo]; ok {
					info.Homepage = meta.Homepage
					info.Topics = strings.Join(meta.Topics, " ")
					info.ChatSetting = meta.Chat
				}
			}
			info.Chat = d.Chat != nil && info.ChatSetting
			if d.Deletable != nil {
				info.Deletable = d.Deletable(in.Repo)
			}
			if repo.Indexing != nil {
				info.Indexing = repo.Indexing()
			}
			if ix := repo.Index; ix != nil {
				// Summary, not CheckinCount: this op reports BOTH numbers
				// a repository has, and reading only one of them is how
				// contributors came back 0 on a repository with 2027
				// check-ins. repo.list answers the same two from the same
				// call (see its own comment), so the two now agree by
				// construction rather than by both being checked.
				//
				// Summary's check-in count is the same query — a count of
				// the checkin table — so nothing moved for that number.
				if sm, err := ix.Summary(); err == nil {
					info.Checkins, info.Contributors = sm.Checkins, sm.Contributors
				}
				if tip, _, err := ix.Timeline(index.TimelineFilter{Limit: 1, Kind: "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[repoImportsIn, repoImportsOut]{
		Name:  "repo.imports",
		Caps:  "s",
		Scope: ops.ScopeFleet,
		Doc:   "Repositories being copied from an upstream, and ones whose copy failed.",
		Kind:  ops.Query,
		Auth:  ops.Admin,
		Route: "GET /api/v1/repos/imports",
		Run: func(ctx context.Context, in repoImportsIn) (repoImportsOut, error) {
			if d.Registry == nil {
				return repoImportsOut{}, fmt.Errorf("this node cannot register repositories: %w", ops.ErrUnavailable)
			}
			rows, err := d.Registry.Imports()
			if err != nil {
				return repoImportsOut{}, err
			}
			if rows == nil {
				rows = []RepoImport{}
			}
			return repoImportsOut{Imports: rows, Count: len(rows)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[repoAddIn, repoAddOut]{
		Name: "repo.add",
		Doc: "Serve a repository: an existing store at `path`, a new empty one (`create`), or a clone of `clone`. " +
			"Cloning also records a mirror unless `noMirror`. Any signed-in account may call this — the creator " +
			"becomes the repository's admin in the same operation, which is where ownership starts.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		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. This used to be a courtesy — only
			// a site admin could reach this op, and site admins already
			// hold every repository — but creation is now open to any
			// account, so the grant is the ONLY thing that makes the
			// repository reachable by its creator. A grant that fails
			// must not leave the repository ownerless with nobody but a
			// site admin able to see it exists: undo the creation and
			// report the failure rather than serve a repository its own
			// creator cannot administer.
			if d.Accounts != nil {
				if who := ops.User(ctx); who != "" {
					if gerr := d.Accounts.Grant(who, in.Name, "admin"); gerr != nil {
						if d.Registry != nil {
							if rerr := d.Registry.Remove(in.Name); rerr != nil {
								log.Printf("repo.add: owner grant for %s on %s failed (%v); "+
									"rollback also failed: %v", who, in.Name, gerr, rerr)
							}
						}
						return repoAddOut{}, fmt.Errorf(
							"created but could not grant %s admin on it, so it was not kept: %w",
							who, ops.ErrUnavailable)
					}
					// No AccessChanged here: the actor and the subject
					// are the same account (you granted yourself
					// admin by creating the repository), and that
					// case is exactly what AccessChanged's own
					// actor == account guard exists to stay silent on.
				}
			}
			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",
		Scope: ops.ScopeRepo,
		Doc: "Set a repository's settings: description, visibility, about fields and chat. " +
			"A field the body does not mention is LEFT ALONE, so a partial update changes only what it names — " +
			"send an explicit empty string or false to clear one. " +
			"private=true blanks the nobody and anonymous category strings (RFC 0004 §Private); false returns them to the fleet default.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		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
			}
			// One patch, carrying only the fields this body named.
			// Nothing is read before the write, so there is no
			// before-image to go stale and no field to write back on
			// someone else's behalf. See RepoEdit.
			edit := RepoEdit{Description: in.Description, Homepage: in.Homepage,
				Chat: in.Chat, Mirrorable: in.Mirrorable}
			if in.Private != nil {
				v := visibilityOf(*in.Private)
				edit.Visibility = &v
			}
			// Checked here rather than by a validate tag: applyRules
			// ignores pointer fields, so the tags these fields used to
			// carry would now pass anything. See the note on the type.
			// Each limit is checked against the value being WRITTEN; a
			// field the body did not name keeps whatever it already
			// passed on the way in.
			if in.Description != nil && len(*in.Description) > 300 {
				return repoUpdateOut{}, fmt.Errorf("description is at most 300 characters: %w", ops.ErrInvalid)
			}
			if in.Homepage != nil {
				if len(*in.Homepage) > 300 {
					return repoUpdateOut{}, fmt.Errorf("homepage is at most 300 characters: %w", ops.ErrInvalid)
				}
				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)
				}
			}
			if in.Topics != nil {
				if len(*in.Topics) > 600 {
					return repoUpdateOut{}, fmt.Errorf("topics are at most 600 characters: %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, " ")
				edit.Topics = &normTopics
			}
			if edit.Any() {
				// ops.User names the account this write is attributed to
				// in the settings log. Empty is a real answer -- an
				// unauthenticated path -- and is recorded as such rather
				// than invented.
				if err := d.Registry.Update(in.Name, edit, ops.User(ctx)); err != nil {
					return repoUpdateOut{}, err
				}
			}
			// What the repository HOLDS, read after the write rather
			// than echoed back from the request. The echo reported the
			// caller's own values whether or not they had reached the
			// database, so a write that did not land still answered as
			// though it had -- which is exactly how a mirrorable flag
			// that never persisted kept answering true.
			now := d.repoSettings(in.Name)
			// RFC 0005 declares repo.settings and nothing raised it, so
			// a webhook subscribed to `repo` heard nothing when a
			// repository was reconfigured and the settings page had no
			// history to show. Raised here, at the one write path.
			if d.Events != nil {
				// The summary is the sentence a reader gets — in the
				// trail, in a webhook body, in the chat robot's line.
				// An event with only a kind makes every one of those
				// surfaces say the kind.
				what := "settings changed"
				if now.private {
					what = "settings changed; the repository is now private"
				}
				if err := d.Events.Raise(evt.RepoSettings, in.Name, map[string]any{
					"repo": in.Name, "actor": ops.User(ctx), "private": now.private,
					"summary": what,
				}); err != nil {
					log.Printf("events: raise %s: %v", evt.RepoSettings, err)
				}
			}
			return repoUpdateOut{Name: in.Name, Description: now.description, Private: now.private,
				Homepage: now.homepage, Topics: now.topics, Mirrorable: now.mirrorable}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[repoLogIn, repoLogOut]{
		Name:  "repo.log",
		Caps:  "a",
		Scope: ops.ScopeRepo,
		Doc: "Who changed this repository's settings, which field, and what it held before. " +
			"One entry per field that actually changed, newest first; a save that changed nothing records none. " +
			"This is the durable record — the event stream keeps 24 hours and says only that settings changed.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/r/{repo}/settings/log",
		Run: func(ctx context.Context, in repoLogIn) (repoLogOut, error) {
			if d.SettingsLog == nil {
				return repoLogOut{}, fmt.Errorf("this node keeps no settings log: %w", ops.ErrUnavailable)
			}
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return repoLogOut{}, err
			}
			limit := in.Limit
			if limit <= 0 {
				limit = 50
			}
			if limit > 500 {
				limit = 500
			}
			rows, err := d.SettingsLog(in.Repo, limit)
			if err != nil {
				return repoLogOut{}, err
			}
			out := repoLogOut{Repo: in.Repo, Changes: make([]settingsChangeOut, 0, len(rows))}
			for _, c := range rows {
				out.Changes = append(out.Changes, settingsChangeOut{At: c.At, Actor: c.Actor,
					Field: c.Field, Was: c.Was, Now: c.Now})
			}
			out.Count = len(out.Changes)
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[repoDeleteIn, repoRemoveOut]{
		Name:  "repo.delete",
		Caps:  "a",
		Scope: ops.ScopeRepo,
		Doc: "Destroy a repository: its store, its index and everything keyed to it. `confirm` must equal `name`. " +
			"Only repositories quarry created can be deleted. Destroying belongs to the DEED, not the role: the " +
			"`a` letter reaches everything inside a repository and stops at ending it, so an admin " +
			"collaborator cannot delete a repository they do not own. A repository nobody has claimed is " +
			"gated on the letter alone, since somebody must be able to retire it.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		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.refuseUnlessTheirs(ctx, in.Name, "destroy"); 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:  "a",
		Scope: ops.ScopeRepo,
		Doc: "Stop serving a repository. The store on disk is left untouched. Belongs to the DEED like " +
			"repo.delete: un-serving somebody's repository is not something a grant to help maintain it carries.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		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.refuseUnlessTheirs(ctx, in.Name, "stop serving"); err != nil {
				return repoRemoveOut{}, err
			}
			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(seesPrivate(ctx))
			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, OpenLandings: c.OpenLandings,
				Technotes: c.Technotes,
			}
			// The forum tab counts what the forum page SHOWS, which for
			// a bound repository is its companion's threads (RFC 0015).
			// Without this the badge counted the parent's own index —
			// on fossil, four legacy threads beside a page listing
			// 3,558 — and a number that disagrees with the page it
			// labels is worse than no number.
			//
			// Added to the parent's own rather than replacing it: a
			// repository that had threads before it was bound still has
			// them, they are still on the page, and dropping them from
			// the count would trade one wrong number for another.
			out.ForumThreads += d.companionForumThreads(ctx, in.Repo)
			// Same rule for the tracker: the tab counts what the page
			// shows, and the page shows both tiers (localOpenTickets).
			out.Tickets += d.localOpenTickets(in.Repo)
			// Whether this repository has any CI at all, so the layout
			// can leave the tab off a repository that has none. Asked
			// as "is there a first run", LIMIT 1, on a query every repo
			// page already makes.
			//
			// RUNS, not runners: quarry's own CI is served by a worker
			// on no group (471 runs against an ungrouped worker at the
			// time of writing), so keying the tab on "this repository
			// has a runner of its own" would have hidden the CI tab of
			// the one repository on this forge that actually runs CI.
			if d.CI != nil {
				if runs, rerr := d.CI.CIRuns(in.Repo, 1); rerr == nil && len(runs) > 0 {
					out.HasCI = true
				}
			}
			if d.CIDecline != nil {
				out.CIDeclinedBy, _ = d.CIDecline.CIDeclined(in.Repo)
			}
			// 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[repoActivityIn, repoActivityOut]{
		Name:  "repo.activity",
		Caps:  "o",
		Doc:   "Sample a repository's recent check-ins — who, when, and which files — with merge-carried touches marked inherited.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/activity",
		Run: func(ctx context.Context, in repoActivityIn) (repoActivityOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return repoActivityOut{}, err
			}
			if err := validateActivityBranch(repo.Index, in.Branch); err != nil {
				return repoActivityOut{}, err
			}
			if err := validateActivityWindow(in.Since, in.Until); err != nil {
				return repoActivityOut{}, err
			}
			a, err := repo.Index.Activity(index.ActivityQuery{
				Branch: in.Branch, User: in.User, Since: in.Since, Until: in.Until})
			if err != nil {
				return repoActivityOut{}, err
			}
			out := repoActivityOut{
				Since: a.Since, Until: a.Until, Paths: a.Paths, Window: a.Window,
				Authors: make([]activityAuthor, len(a.Authors)),
				Rows:    make([]activityRow, len(a.Rows)),
				Touches: make([]activityTouch, len(a.Touches)),
			}
			for i, au := range a.Authors {
				out.Authors[i] = activityAuthor{Name: au.Name, Checkins: au.Checkins,
					Files: au.Files, Hue: au.Hue, Chroma: au.Chroma, Shade: au.Shade}
			}
			for i, row := range a.Rows {
				out.Rows[i] = activityRow{Day: row.Day, Hour: row.Hour, Author: row.Author,
					Merge: row.Merge, Files: row.Files, Hash: row.Hash, Branch: row.Branch}
			}
			for i, t := range a.Touches {
				out.Touches[i] = activityTouch{Day: t.Day, Path: t.Path,
					Author: t.Author, Inherited: t.Inherited}
			}
			if out.Paths == nil {
				out.Paths = []string{}
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[repoActivityDaysIn, repoActivityDaysOut]{
		Name:  "repo.activity.days",
		Caps:  "o",
		Doc:   "Fold a repository's whole history to one entry per day — who led it, how many merges, which directory, which releases.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/activity/days",
		Run: func(ctx context.Context, in repoActivityDaysIn) (repoActivityDaysOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return repoActivityDaysOut{}, err
			}
			if err := validateActivityBranch(repo.Index, in.Branch); err != nil {
				return repoActivityDaysOut{}, err
			}
			a, err := repo.Index.ActivityDays(in.Branch)
			if err != nil {
				return repoActivityDaysOut{}, err
			}
			out := repoActivityDaysOut{
				First: a.First, Last: a.Last, Paths: a.Paths, Releases: a.Releases,
				Authors: make([]activityAuthor, len(a.Authors)),
				Days:    make(map[string]activityDay, len(a.Days)),
			}
			for i, au := range a.Authors {
				out.Authors[i] = activityAuthor{Name: au.Name, Checkins: au.Checkins,
					Files: au.Files, Hue: au.Hue, Chroma: au.Chroma, Shade: au.Shade}
			}
			for k, v := range a.Days {
				day := activityDay{N: v.N, Merges: v.Merges,
					Authors: v.Authors, Path: v.Path, Top: make([][]int, len(v.Top))}
				for i, top := range v.Top {
					day.Top[i] = []int{top[0], top[1]}
				}
				out.Days[k] = day
			}
			if out.Paths == nil {
				out.Paths = []string{}
			}
			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(index.TimelineFilter{
				Limit: in.Limit, Before: in.Before, Branch: in.Branch, Kind: in.Y,
				Tag: in.Tag, User: in.User})
			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[zitcli.LeafKey(entries[i].Hash)]
				}
			} else {
				log.Printf("timeline %s: leaves: %v", in.Repo, lerr)
			}
			out := timelineOut{Entries: entries, Count: len(entries), IndexBehind: behind}
			if hideCI(ctx) {
				for i := range out.Entries {
					out.Entries[i].CI = ""
				}
			}
			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. Site-admin only, " +
			"unlike the rest of a repository's own settings: a replay reads every artifact in the store and " +
			"competes with every other repository for the machine, which makes it forge performance rather " +
			"than repository business.",
		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",
		Scope: ops.ScopeCaller,
		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.
			// perRepo must exceed the page, or one busy repository
			// could fill a page and leave nothing to page WITH.
			//
			// feedDepth is how far back this feed reaches. A landing
			// page is a window on recent activity, not an archive: this
			// corpus holds a quarter of a million events, and a pager
			// ten thousand pages long is not a control anyone can use —
			// nor could a deep offset be served without merging
			// millions of rows. The archive is each repository's own
			// timeline, which pages by cursor and has no such bound.
			const perRepo, feedCap, mineCap, repoCap, feedDepth = 60, 10, 20, 25, 1000
			user, names, roles, err := d.myReposWithRoles(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
				}
				// Each tracker must offer everything up to the end of
				// this page, since any one of them could supply all of
				// it — the merge decides which rows survive.
				//
				// The kind is asked of the INDEX, not filtered out of
				// the answer. Fetching a window of the timeline and
				// then dropping the kinds we did not want made a chip's
				// pages disagree with its count: a repository with 234
				// ticket events among thousands of check-ins offered
				// ten pages of them and had rows on none, because the
				// window held check-ins and nothing else. event_by_kind
				// covers this, so it is also the cheaper way to ask.
				entries, _, terr := repo.Index.Timeline(index.TimelineFilter{Limit: in.Offset + perRepo, Kind: in.Kind})
				if terr != nil {
					continue // one broken index must not empty the page
				}
				// A companion is a store, not a project (RFC 0015): it
				// belongs on Fleet with its parent and nowhere else,
				// and its activity is the PARENT's — a forum thread in
				// fossil-forum is something that happened to fossil.
				// repo.list has filtered these since the binding
				// existed; the dashboard is the other list of
				// repositories and had not.
				under := name
				if parent := d.companionParent(name); parent != "" {
					under = parent
				} else {
					dr := dashRepo{Name: name, Description: desc[name], Role: roles[name]}
					if len(entries) > 0 {
						dr.LastActivity = entries[0].Date
					}
					if open, _, cerr := repo.Index.TicketCounts(index.TicketFilter{IncludePrivate: seesPrivate(ctx)}); cerr == nil {
						dr.Open = open
					}
					if in.Find == "" || strings.Contains(strings.ToLower(name), strings.ToLower(in.Find)) {
						out.Repos = append(out.Repos, dr)
					}
				}
				if in.Repo != "" && under != in.Repo {
					continue // listed, but not merged into the feed
				}
				for _, e := range entries {
					all = append(all, feedEvent{Repo: under, Kind: e.Kind,
						Ref: e.Ref, Hash: e.Hash, Date: e.Date,
						User: e.User, Comment: e.Comment})
				}
			}
			// Most recently active first, NAME as the tiebreak. Without
			// the tiebreak two repositories with equal activity — or
			// none at all — kept whatever order the map walk produced,
			// so the list reshuffled between loads for no reason a
			// reader could see.
			sort.SliceStable(out.Repos, func(i, j int) bool {
				if out.Repos[i].LastActivity != out.Repos[j].LastActivity {
					return out.Repos[i].LastActivity > out.Repos[j].LastActivity
				}
				return out.Repos[i].Name < out.Repos[j].Name
			})
			// (date, hash) descending — the SAME total order the cursor
			// encodes. Sorting by date alone would make the cursor
			// ambiguous wherever two events share a timestamp.
			sort.SliceStable(all, func(i, j int) bool {
				if all[i].Date != all[j].Date {
					return all[i].Date > all[j].Date
				}
				return all[i].Hash > all[j].Hash
			})
			// The chips count the WHOLE feed, not the page on screen:
			// the feed pages, so a number taken from one window would
			// promise less than clicking it delivers. The same counts
			// give the pager its length.
			byKind := map[string]int{}
			for _, name := range names {
				if in.Repo != "" && name != in.Repo {
					continue
				}
				repo, ok := d.Repos.Get(name)
				if !ok || repo.Index == nil {
					continue
				}
				ks, kerr := repo.Index.EventKinds()
				if kerr != nil {
					continue
				}
				for k, n := range ks {
					byKind[k] += n
					out.Total += n
				}
			}
			// Landings are one chip: the record and the replies on it
			// are two kinds in the index and one thing to a reader, and
			// the kind filter "l" already reaches both.
			byKind["l"] += byKind["lc"]
			for _, k := range []string{"ci", "t", "w", "f", "e", "l"} {
				if n := byKind[k]; n > 0 {
					out.Kinds = append(out.Kinds, kindCount{Kind: k, Count: n})
				}
			}
			if in.Kind != "" {
				out.Total = byKind[in.Kind]
			}
			// What the pager may reach, which is not the same question
			// as how much there is. Reported separately so the page can
			// say both without either standing in for the other.
			out.Depth = feedDepth
			if out.Total < feedDepth {
				out.Depth = out.Total
			}
			for i, e := range all {
				// Mine is the caller's own recent work within the same
				// view, and always from the newest end however deep the
				// reader has paged — it answers "what have I been
				// doing", which no page number changes.
				if e.User == user && len(out.Mine) < mineCap {
					out.Mine = append(out.Mine, e)
				}
				if i >= in.Offset && 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(seesPrivate(ctx)); cerr == nil {
					out.Contributors = c.Contributors
				}
				if notes, nerr := repo.Index.Technotes(); nerr == nil && len(notes) > 0 {
					out.NoteEvent, out.NoteTitle = notes[0].Event, notes[0].Comment
					out.NoteDate, out.NoteUser = notes[0].Date, notes[0].User
					out.NoteExcerpt = notes[0].Excerpt
					out.NoteCount = len(notes)
				}
				if d.Forum != nil {
					if cats, cerr := d.Forum.ThreadCategories(in.Repo); cerr == nil && len(cats) > 0 {
						// Newest-first until the first announcement:
						// unbounded because an announcement may be old
						// and the panel names the latest one there is.
						if threads, terr := repo.Index.ForumThreads(0, ""); 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.
				// Names only: markerDocs reads .Name and nothing else.
				if ls, lerr := repo.Zit.LsNames(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 {
					res := docResolver{resolver{ix: repo.Index, repo: in.Repo}, version, docDirOf(sel.Path)}
					if html, herr := renderBodyWith(res, 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
			}
			// One merge, shared with the check that refuses a value outside
			// this list: a forge must not advertise one vocabulary and
			// enforce another.
			merged, source, problem := d.mergedTicketVocab(ctx, repo)
			out := ticketConfigOut{Source: source, Problem: problem}
			for _, f := range vocabFieldOrder {
				out.Vocab = append(out.Vocab, vocabField{Field: f, Words: merged[f]})
			}
			// The tracker's own columns, so a form can offer the ones
			// fossil never shipped. Withheld fields are dropped here
			// rather than at the form: this op is anonymous, and a
			// reader must not learn a tracker holds contact addresses.
			if repo.Index != nil {
				uses, uerr := repo.Index.TicketFieldNames()
				if uerr != nil {
					return ticketConfigOut{}, uerr
				}
				for _, u := range uses {
					if withheldFields[u.Name] {
						continue
					}
					out.Fields = append(out.Fields, u)
				}
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := d.registerLocalTickets(r); err != nil {
		return err
	}
	if err := d.registerTeamBoard(r); err != nil {
		return err
	}
	if err := d.registerTeamChat(r); err != nil {
		return err
	}
	if err := registerFossilUsers(r, d); err != nil {
		return err
	}
	if err := d.registerTeamRender(r); err != nil {
		return err
	}
	if err := d.registerTeams(r); err != nil {
		return err
	}
	if err := d.registerAccountCaps(r); 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
			}
			if _, ok := index.TicketOrders[in.Order]; !ok {
				return ticketListOut{}, fmt.Errorf("order %q is not one of: %s: %w",
					in.Order, strings.Join(orderNames(), ", "), ops.ErrInvalid)
			}
			if err := d.refuseUnknownFilter(ctx, repo, map[string]string{
				"status": in.Status, "type": in.Type,
				"severity": in.Severity, "priority": in.Priority,
			}); err != nil {
				return ticketListOut{}, err
			}
			filter := index.TicketFilter{
				IncludePrivate: seesPrivate(ctx),
				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(seesPrivate(ctx)); err != nil {
				return ticketListOut{}, err
			}
			for _, t := range all {
				// summaryOf, not a second literal: this one was written
				// out by hand and lost assignee, so a tracker's own
				// board grouped every ticket as unassigned while the
				// index held 8,940 of them. One conversion, one place
				// for a field to be added to.
				out.Tickets = append(out.Tickets, summaryOf(t))
			}
			if more && len(all) > 0 {
				last := all[len(all)-1]
				out.Next = last.Updated + "~" + last.ID
			}
			// The local tier (docs/local-tickets.md) rides above the
			// archive: an intake queue is triage work, not history, so
			// it appears on the FIRST page only — cursor and offset
			// pages stay purely fossil — and above any chosen order.
			if in.Origin == "local" {
				out.Tickets, out.Count, out.Open, out.Closed, out.Next = nil, 0, 0, 0, ""
			}
			if d.Local != nil && in.Origin != "fossil" && in.Before == "" && in.Offset == 0 {
				ls, err := d.Local.LocalTickets(in.Repo)
				if err != nil {
					return ticketListOut{}, err
				}
				var rows []ticketSummary
				for _, lt := range ls {
					row := localSummary(lt)
					if !localMatches(row, in) {
						continue
					}
					rows = append(rows, row)
					if localClosed(row.Status) {
						out.Closed++
					} else {
						out.Open++
					}
				}
				out.Tickets = append(rows, out.Tickets...)
				out.Count = len(out.Tickets)
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ticketAcrossIn, ticketAcrossOut]{
		Name:  "ticket.across",
		Scope: ops.ScopeCaller,
		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{
				IncludePrivate: seesPrivate(ctx),
				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"
			}
			// AssigneeSet, not just Assignee: the empty string is a
			// real value there (unassigned), so the flag is what makes
			// "assigned to me" different from "any assignee".
			if in.Mine {
				filter.Assignee, filter.AssigneeSet = user, true
			}
			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{IncludePrivate: seesPrivate(ctx)}); ferr == nil && o > 0 {
						out.Repos = append(out.Repos, index.TicketFacet{Value: name, Open: o})
					}
					if rs, as, ferr := repo.Index.TicketFacets(seesPrivate(ctx)); 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: summaryOf(t)})
				}
			}
			// 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",
			Scope: ops.ScopeCaller,
			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
				}
				if views == nil {
					// An account with no saved views has an EMPTY list,
					// not a null. A reader took {"views": null} for "saved
					// views are not listed here" (763c5c89).
					views = []oltp.SavedView{}
				}
				return viewListOut{Views: views}, nil
			},
		}); err != nil {
			return err
		}
		if err := ops.Register(r, ops.Op[viewSaveIn, viewSaveOut]{
			Name:  "view.save",
			Scope: ops.ScopeCaller,
			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",
			Scope: ops.ScopeCaller,
			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[landingOpenIn, landingOpenOut]{
		Name:  "landing.open",
		Caps:  "3",
		Doc:   "Propose a branch to land on a target. Mints a forum thread — no new artifact kind — so a stock fossil clone reads it as an ordinary discussion.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/landing",
		Run: func(ctx context.Context, in landingOpenIn) (landingOpenOut, error) {
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return landingOpenOut{}, err
			}
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return landingOpenOut{}, err
			}
			target := in.Target
			if target == "" {
				target = "trunk" // zit's own default, restated so the record reads complete
			}
			// The courtesy half of the one-open-landing-per-branch rule
			// (zit#127): refuse the second by name rather than let a
			// forge quietly hold two proposals for one branch with
			// nothing saying which accept should honour.
			//
			// Only a courtesy — two peers can each open one in separate
			// clones and both are valid artifacts when they meet, which
			// is why the fold decides a winner regardless.
			if repo.Index != nil {
				if l, found, lerr := repo.Index.OpenLandingOn(in.Branch); lerr == nil && found {
					return landingOpenOut{}, fmt.Errorf("%w: %s already has an open landing (%.12s)",
						ops.ErrInvalid, in.Branch, l.ID)
				}
			}
			var id string
			if err := repo.write(func() (werr error) {
				id, werr = repo.Zit.LandingOpen(author(ctx), in.Branch, target, in.Message, authoredMimetype)
				return werr
			}); err != nil {
				return landingOpenOut{}, err
			}
			if err := repo.synced(); err != nil {
				return landingOpenOut{}, err
			}
			d.fireNotify(NotifyEvent{
				Workflow: "quarry-activity", To: "repo:" + in.Repo,
				Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
					"summary": "proposed " + in.Branch + " to land on " + target,
					"link":    d.link("/r/" + in.Repo + "/landing/" + id)},
				TxnID: "activity:landing:" + id,
			})
			// The activity topic is a digest, hours later. A landing onto
			// a protected branch is waiting on its OWNERS, and the person
			// it is waiting on should hear now, on the landing family —
			// which is immediate — rather than find it in the next
			// batch. Not the actor: they know.
			d.notifyOpened(ctx, in.Repo, id, in.Branch, target)
			// A required check with no verdict has no way forward on
			// its own, so opening the landing asks for the run. The
			// ordinary case fires nothing: the fold loop already ran
			// this tip when it was pushed, and a run that reported
			// pass, fail, or is still in flight is left alone.
			//
			// Failing to start one must not fail the open. The landing
			// exists either way, and the panel offers Re-run.
			if d.CITrigger != nil {
				sum := index.LandingSummary{Branch: in.Branch, Target: target}
				// The BRANCH tip, named. An empty check-in means the
				// repository's newest one, which on a branch behind
				// trunk is somebody else's work — the re-run button
				// can mean "the tip" because a person pressed it on a
				// page about one; this cannot.
				//
				// A premerge subject is a PAIR, not a check-in, and
				// starting one is quarry-premerge's job rather than a
				// hash this op could pass. Left to the re-run button.
				if tip, premerge := d.ciSubject(in.Repo, sum); tip != "" && !premerge {
					if missing := d.verdictlessChecks(in.Repo, sum); len(missing) > 0 {
						if err := d.CITrigger(in.Repo, tip); err != nil {
							log.Printf("ci: %s: landing %.12s opened with no verdict for %v, could not start a run: %v",
								in.Repo, id, missing, err)
						}
					}
				}
			}
			return landingOpenOut{ID: id}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[landingSayIn, landingSayOut]{
		Name:  "landing.comment",
		Caps:  "3",
		Doc:   "Add a remark to a landing's discussion. An ordinary forum reply.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/landing/{id}/comment",
		Run: func(ctx context.Context, in landingSayIn) (landingSayOut, error) {
			return d.landingSay(ctx, in, "comment")
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[landingSayIn, landingSayOut]{
		Name:  "landing.reject",
		Caps:  "i",
		Doc:   "Settle a landing without merging. A decision, so it takes the check-in capability like accept; a remark needs only the forum letter.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/landing/{id}/reject",
		Run: func(ctx context.Context, in landingSayIn) (landingSayOut, error) {
			return d.landingSay(ctx, in, "reject")
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[landingSayIn, landingSayOut]{
		Name: "landing.accept",
		// The one landing op that is not a forum cap. accept MERGES:
		// it writes a check-in to the target branch, so it takes `i` —
		// fossil's Write/check-in letter, which RFC 0004 stored and
		// left inert until a surface needed it. This is that surface.
		//
		// There is deliberately no rule against accepting your own
		// landing. Inside a cathedral the author already holds the
		// commit bit; a required second pair of eyes is a policy some
		// projects want and fossil has no letter for, so inventing one
		// here would be quarry policy wearing fossil's clothes.
		Caps:  "i",
		Doc:   "Land the branch on its target and settle the record. Writes a merge check-in, so it needs the check-in capability rather than the forum one.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/landing/{id}/accept",
		Run: func(ctx context.Context, in landingSayIn) (landingSayOut, error) {
			return d.landingSay(ctx, in, "accept")
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[landingAutoIn, landingAutoOut]{
		Name: "landing.auto",
		// Arming takes the accept's own letter, because it IS an accept
		// — deferred until the checks go green, then written by the
		// arming account's hand through landing.accept's one path, so
		// protection and the required set gate at fire time exactly as
		// a button press would.
		Caps:  "i",
		Doc:   "Arm or disarm auto-land: an armed open landing accepts itself once every required check on its target is green. The deferred accept is the ordinary one, written as the arming account.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/landing/{id}/auto",
		Run: func(ctx context.Context, in landingAutoIn) (landingAutoOut, error) {
			if d.Auto == nil {
				return landingAutoOut{}, fmt.Errorf("this forge has no store for auto-land: %w", ops.ErrUnavailable)
			}
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return landingAutoOut{}, err
			}
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return landingAutoOut{}, err
			}
			l, ok, err := repo.Index.LandingByPrefix(in.ID)
			if err != nil {
				return landingAutoOut{}, err
			}
			if !ok {
				return landingAutoOut{}, fmt.Errorf("no landing %q: %w", in.ID, ops.ErrNotFound)
			}
			if !in.On {
				if err := d.Auto.ClearAutoAccept(in.Repo, l.ID); err != nil {
					return landingAutoOut{}, err
				}
				return landingAutoOut{}, nil
			}
			if l.State != "open" {
				return landingAutoOut{}, fmt.Errorf("the record is %s; there is nothing left to land: %w", l.State, ops.ErrInvalid)
			}
			if err := d.Auto.SetAutoAccept(in.Repo, l.ID, ops.User(ctx)); err != nil {
				return landingAutoOut{}, err
			}
			return landingAutoOut{AutoBy: ops.User(ctx)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[landingSayIn, landingSayOut]{
		Name:  "landing.update",
		Caps:  "i",
		Doc:   "Bring the target into the proposed branch, on the store, as a merge check-in (zit#152). Refused when the merge would not be clean; nothing is written. Writes to the PROPOSED branch, so it needs the check-in capability but not ownership of a protected target: membership of the target does not change.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/landing/{id}/update",
		Run: func(ctx context.Context, in landingSayIn) (landingSayOut, error) {
			return d.landingSay(ctx, in, "update")
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[landingLandIn, landingLandOut]{
		Name:  "landing.land",
		Caps:  "i",
		Doc:   "Land a strata bottom-up, up to and including the named rung. Each rung is an ordinary accept; zit retargets the next; the first rung that will not merge stops the walk and is reported, with everything below it landed.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/landing/{id}/land",
		Run: func(ctx context.Context, in landingLandIn) (landingLandOut, error) {
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return landingLandOut{}, err
			}
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return landingLandOut{}, err
			}
			want, ok, err := repo.Index.LandingByPrefix(in.ID)
			if err != nil {
				return landingLandOut{}, err
			}
			if !ok {
				return landingLandOut{}, fmt.Errorf("no landing %q: %w", in.ID, ops.ErrNotFound)
			}
			// Landed is a list even when empty: a caller that asks what
			// landed should read [], not null.
			out := landingLandOut{Landed: []string{}}
			// One accept per rung, bottom-up, re-reading the chain after
			// each because zit#144 has retargeted the next rung by then.
			// Bounded by the stack's height as first read, plus one: a
			// chain that keeps growing under us is a fault, not a loop.
			all, err := repo.Index.Landings("", 0)
			if err != nil {
				return landingLandOut{}, err
			}
			index.AttachStrata(all)
			limit := 1
			for _, l := range all {
				if l.ID == want.ID && l.Depth > 0 {
					limit = l.Depth
				}
			}
			for step := 0; step <= limit; step++ {
				all, err := repo.Index.Landings("", 0)
				if err != nil {
					return out, err
				}
				index.AttachStrata(all)
				var me *index.LandingSummary
				for i := range all {
					if all[i].ID == want.ID {
						me = &all[i]
					}
				}
				if me == nil {
					return out, fmt.Errorf("landing %q vanished mid-walk: %w", in.ID, ops.ErrUnavailable)
				}
				if me.State != "open" {
					out.Remaining = 0
					return out, nil
				}
				if me.Broken != "" {
					out.Stopped, out.Reason, out.Remaining = me.ID, me.Broken, me.Position
					return out, nil
				}
				// The rung to accept now is the base of the chain the
				// wanted rung sits on — or the wanted rung itself when
				// it is standalone.
				next := me
				if me.Stack != "" {
					for i := range all {
						if all[i].Stack == me.Stack && all[i].Position == 1 {
							next = &all[i]
						}
					}
				}
				out.Remaining = me.Position
				if out.Remaining == 0 {
					out.Remaining = 1
				}
				if _, err := d.landingSay(ctx, landingSayIn{Repo: in.Repo, ID: next.ID,
					Message: in.Message}, "accept"); err != nil {
					out.Stopped, out.Reason = next.ID, err.Error()
					return out, nil
				}
				out.Landed = append(out.Landed, next.ID)
			}
			return out, fmt.Errorf("the strata kept growing while landing: %w", ops.ErrUnavailable)
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[hookDeliveriesIn, hookDeliveriesOut]{
		Name: "hook.deliveries",
		Caps: "a",
		Doc: "What one webhook endpoint actually received: recent attempts with their status, latency and error. " +
			"The events page says what quarry RAISED; this says what arrived, and the two differ exactly when something is broken.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/r/{repo}/hook/{sub}/deliveries",
		Run: func(ctx context.Context, in hookDeliveriesIn) (hookDeliveriesOut, error) {
			if d.Hooks == nil {
				return hookDeliveriesOut{Deliveries: []HookDelivery{}}, nil
			}
			ds, err := d.Hooks.Deliveries(in.Repo, in.Sub, in.Limit)
			if err != nil {
				return hookDeliveriesOut{}, err
			}
			return hookDeliveriesOut{Deliveries: ds, Count: len(ds)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[hookSendIn, hookSendOut]{
		Name: "hook.redeliver",
		Caps: "a",
		Doc: "Send a recorded delivery again, byte for byte. ID names the DELIVERY. " +
			"The stored body is replayed rather than the event rebuilt, so the signature is the one the endpoint saw before.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/hook/delivery/{id}/redeliver",
		Run: func(ctx context.Context, in hookSendIn) (hookSendOut, error) {
			if d.Hooks == nil {
				return hookSendOut{}, fmt.Errorf("this forge has no delivery worker: %w", ops.ErrUnavailable)
			}
			o, err := d.Hooks.Redeliver(in.Repo, in.ID)
			if err != nil {
				return hookSendOut{}, err
			}
			return hookSendOut{Sent: o.Sent(), Status: o.Status, Ms: o.Ms, Err: o.Err}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[hookSendIn, hookSendOut]{
		Name: "hook.ping",
		Caps: "a",
		Doc: "Send a signed test event to one endpoint. ID names the SUBSCRIPTION. " +
			"Proves an endpoint is reachable and its signature check works, without waiting for something real to happen.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/hook/{id}/ping",
		Run: func(ctx context.Context, in hookSendIn) (hookSendOut, error) {
			if d.Hooks == nil {
				return hookSendOut{}, fmt.Errorf("this forge has no delivery worker: %w", ops.ErrUnavailable)
			}
			o, err := d.Hooks.Ping(in.Repo, in.ID)
			if err != nil {
				return hookSendOut{}, err
			}
			return hookSendOut{Sent: o.Sent(), Status: o.Status, Ms: o.Ms, Err: o.Err}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[unproposedIn, unproposedOut]{
		Name: "branch.unproposed",
		Caps: "2",
		Doc: "Branch tips whose work nobody has proposed: an open leaf, not the default branch, with no open landing naming it. " +
			"The blind spot of the landing queue — work that is finished and not going anywhere, which no other surface reports.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/branch/unproposed",
		Run: func(ctx context.Context, in unproposedIn) (unproposedOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return unproposedOut{}, err
			}
			branches, err := repo.Index.BranchesLimited(0, true)
			if err != nil {
				return unproposedOut{}, err
			}
			// Every state, not just open: a branch whose landing was
			// REJECTED is unproposed again, and one already accepted is
			// not — the question is whether a record still speaks for
			// this branch, and only an open one does.
			ls, err := repo.Index.Landings("open", 0)
			if err != nil {
				return unproposedOut{}, err
			}
			proposed := map[string]bool{}
			for _, l := range ls {
				proposed[l.Branch] = true
			}
			// Leaf-ness is zit's own answer, from its derivation cache.
			// It is what separates a branch with work outstanding from
			// one already merged: a merged tip has the merge check-in
			// for a child, so it is not a leaf. Without a store handle
			// the question cannot be answered, and answering it wrong
			// would send someone to propose work that already landed.
			if repo.Leaves == nil {
				return unproposedOut{}, nil
			}
			leaves, err := repo.Leaves()
			if err != nil {
				return unproposedOut{}, err
			}
			deft, deftTip := defaultBranchOf(repo)
			out := unproposedOut{Rows: []unproposedRow{}}
			for _, b := range branches {
				if b.Name == deft || proposed[b.Name] || !leaves[leafKey(b.Tip)] {
					continue
				}
				out.Rows = append(out.Rows, unproposedRow{
					Branch: b.Name, Tip: b.Tip, Date: b.Date, Target: deft})
			}
			// And the question none of the four cheap ones asked: is the
			// work already in the target? Landing a branch normally
			// CLOSES it, so the leaf test covers the usual case — but a
			// branch landed and left open is not going anywhere, and
			// listing it sends a reader to open a record that says
			// "merges up-to-date" on arrival.
			//
			// Last, and only on the survivors, because it is the only
			// one that costs a subprocess. See dropLandedRows for the
			// cap and what happens past it.
			out.Rows = dropLandedRows(repo, out.Rows, deftTip)
			out.Count = len(out.Rows)
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[branchLandedIn, branchLandedOut]{
		Name: "branch.merges",
		Caps: "2",
		Doc: "Where a branch stands relative to the repository's default branch, asked of the STORE: clean, up-to-date, unrelated or conflict. " +
			"`landed` is the one question a propose-a-landing offer turns on — a branch whose work is already in the target has nothing to propose, and offering it opens a record that says up-to-date on arrival. " +
			"Empty state means no probe could run, and `landed` is then false: withholding an offer because a subprocess failed would hide real stranded work behind an infrastructure fault.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/branch/merges",
		Run: func(ctx context.Context, in branchLandedIn) (branchLandedOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return branchLandedOut{}, err
			}
			deft, deftTip := defaultBranchOf(repo)
			outv := branchLandedOut{Target: deft, Tip: tipOfBranch(repo.Index, in.Branch)}
			if outv.Tip == "" || deftTip == "" || repo.Zit == nil {
				return outv, nil
			}
			// The probe is memoised on the pair of tips, so a page that
			// asks this on every view pays once per push.
			state, _, perr := repo.Zit.MergeProbe(outv.Tip, deftTip, outv.Tip+":"+deftTip)
			if perr != nil {
				// Reported as "no answer", not as an error: this op
				// exists to decide whether to draw a control, and a
				// failed subprocess must not take the page with it.
				return outv, nil
			}
			outv.State = state
			outv.Landed = state == "up-to-date"
			return outv, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[landingListIn, landingListOut]{
		Name:  "landing.list",
		Caps:  "2",
		Doc:   "Landings in this repository: branches proposed to land on a target, newest activity first. A landing is a forum thread, so it reads on the forum capability.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/landing",
		Run: func(ctx context.Context, in landingListIn) (landingListOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return landingListOut{}, err
			}
			// `all` and absent mean the same thing to the query. They
			// are kept distinct in the INPUT because one is a caller
			// saying "every state" and the other is a caller saying
			// nothing, and only the first should survive a round trip
			// through a saved URL.
			state := in.State
			if state == "all" {
				state = ""
			}
			ls, err := repo.Index.Landings(state, in.Limit)
			if err != nil {
				return landingListOut{}, err
			}
			// The open count is of the whole record set, not this page:
			// "what is awaiting decision" is the question the surface
			// exists for, and a filtered listing must not change it.
			// The whole tracker, every state, so a segment stacked on a
			// rejected base is reported as such rather than read as a
			// base of its own (RFC 0006). The open count counts
			// DECISIONS: a stack of five is one.
			all, err := repo.Index.Landings("", 0)
			if err != nil {
				return landingListOut{}, err
			}
			index.AttachStrata(all)
			reading := map[string]index.LandingSummary{}
			for _, l := range all {
				reading[l.ID] = l
			}
			for i := range ls {
				if a, ok := reading[ls[i].ID]; ok {
					ls[i].Stack, ls[i].Position, ls[i].Depth, ls[i].Broken = a.Stack, a.Position, a.Depth, a.Broken
				}
			}
			// The verdict comes from the branch tip's tag, so the queue
			// can show a run's result without a quarry-only status
			// store. A failure to read it leaves the column empty,
			// which reads as "no run" — the honest default when the
			// answer is genuinely unknown.
			branches := make([]string, 0, len(ls))
			for _, l := range ls {
				branches = append(branches, l.Branch)
			}
			if vs, verr := repo.Index.VerdictsForLandings(branches); verr == nil {
				for i := range ls {
					ls[i].CI = vs[ls[i].Branch]
				}
			}
			if hideCI(ctx) {
				for i := range ls {
					ls[i].CI = ""
				}
			}
			return landingListOut{Landings: ls, Count: len(ls), Open: index.Decisions(all)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[landingGetIn, landingGetOut]{
		Name:  "landing.get",
		Caps:  "2",
		Doc:   "One landing: the record, its discussion, and whether the caller may accept it.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/landing/{id}",
		Run: func(ctx context.Context, in landingGetIn) (landingGetOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return landingGetOut{}, err
			}
			l, ok, err := repo.Index.Landing(in.ID)
			if err != nil {
				return landingGetOut{}, err
			}
			if !ok {
				// The table's key is the full thread-root hash; a page
				// links with a prefix, so resolve it the way every
				// other prefix in this forge resolves.
				if full, found, rerr := repo.Index.LandingByPrefix(in.ID); rerr == nil && found {
					l, ok = full, true
				}
			}
			if !ok {
				return landingGetOut{}, fmt.Errorf("no landing %q: %w", in.ID, ops.ErrNotFound)
			}
			out := landingGetOut{Landing: l, CanAccept: ops.CapsOf(ctx).HasAny("i")}
			out.ChatRefs = d.chatRefRowFor(in.Repo, l.ID)
			// Best-effort, like the stack and the protection below it:
			// a record that cannot list its files is still a record
			// worth reading, and the decision on this page does not
			// depend on them.
			if att, aerr := d.attachmentsOn(repo, in.Repo, l.ID); aerr == nil {
				out.Attachments = att
			}
			out.Protected, out.Owners = d.protectionOf(in.Repo, l.Target)
			var stackWhy string
			if all, aerr := repo.Index.Landings("", 0); aerr == nil {
				index.AttachStrata(all)
				out.Stack = index.StrataOf(all, l.ID)
				stackWhy = stackBlock(all, l.ID)
				// Each rung's verdict, and the top's as the strata's.
				branches := make([]string, 0, len(out.Stack.Segments))
				for _, sgm := range out.Stack.Segments {
					branches = append(branches, sgm.Branch)
				}
				if vs, verr := repo.Index.VerdictsForLandings(branches); verr == nil {
					for i := range out.Stack.Segments {
						out.Stack.Segments[i].CI = vs[out.Stack.Segments[i].Branch]
					}
					if n := len(out.Stack.Segments); n > 0 {
						out.Stack.CI = vs[out.Stack.Segments[n-1].Branch]
					}
				}
				// Above the bottom of a stack the accept would fail
				// (zit#144 refuses it): the control is not offered.
				for _, sgm := range out.Stack.Segments {
					if sgm.ID == l.ID && !sgm.Landable {
						out.Mergeable = "stacked"
					}
				}
			}
			out.MayLand = out.CanAccept &&
				(d.Protect == nil || d.Protect.MayJoin(in.Repo, ops.User(ctx), l.Target))
			if stackWhy != "" {
				out.MayLand, out.MayLandReason = false, stackWhy
			}
			if ms, merr := repo.Index.MergesForLandings([]string{l.ID}); merr == nil {
				out.Merge = ms[l.ID]
			}
			if cs, cerr := repo.Index.BranchCommits(l.Branch, 50); cerr == nil {
				out.Commits = cs
			}
			if vs, verr := repo.Index.VerdictsForLandings([]string{l.Branch}); verr == nil {
				out.Landing.CI = vs[l.Branch]
			}
			// Once settled, the verdict a reader needs is the MERGE's.
			// The tip's answer was the right one to decide on and the
			// wrong one to act on afterwards: the tip describes a
			// proposal, the merge describes what the target now IS.
			// Reporting only the tip is what left this record saying
			// "all checks have passed" beside a timeline saying the
			// check-in it produced failed.
			if l.State != "open" && out.Merge.Checkin != "" {
				entries := []index.TimelineEntry{{Hash: out.Merge.Checkin}}
				if cerr := attachCI(ctx, repo, entries); cerr == nil {
					out.MergeCI = entries[0].CI
				}
				if d.CI != nil {
					if runs, rerr := d.CI.CIRunsFor(in.Repo, out.Merge.Checkin); rerr == nil && len(runs) > 0 {
						out.MergeCIRun = runs[0].RunID
					}
				}
			}
			// RFC 0009: the checks panel and the accept gate read the
			// same answers, computed once here so they cannot disagree.
			out.Checks, out.Required = []CIRun{}, []string{}
			if subject, premerge := d.ciSubject(in.Repo, l); subject != "" {
				out.Premerge = premerge
				if d.CI != nil {
					var runs []CIRun
					var rerr error
					if premerge {
						runs, rerr = d.CI.CIRunsForPair(in.Repo, subject)
					} else {
						runs, rerr = d.CI.CIRunsFor(in.Repo, subject)
					}
					if rerr == nil {
						out.Checks = runs
					}
				}
				// External checks share the panel: one vocabulary of
				// rows, whoever produced them (RFC 0009 §external-ci).
				if d.External != nil {
					if ecs, eerr := d.External.ExternalChecks(in.Repo, subject); eerr == nil {
						for _, ec := range ecs {
							out.Checks = append(out.Checks, CIRun{
								URL: ec.URL, Checkin: subject, Workflow: ec.Context,
								Pipeline: "external", Status: ec.State, Started: ec.Updated})
						}
					}
				}
			}
			if d.Checks != nil {
				if req := d.Checks.RequiredChecks(in.Repo, l.Target); req != nil {
					out.Required = req
				}
			}
			out.Unmet = d.unmetChecks(in.Repo, l)
			out.NeverRan = d.verdictlessChecks(in.Repo, l)
			out.MayOverride = d.mayOverride(ctx, in.Repo, l.Target)
			if d.Auto != nil {
				if by, ok := d.Auto.AutoAcceptOf(in.Repo, l.ID); ok {
					out.AutoBy = by
					if r, ok := d.Auto.(AutoReporter); ok {
						if st, found := r.AutoStateOf(in.Repo, l.ID); found {
							out.AutoWaiting, out.AutoWaitingAt = st.Waiting, st.At
						}
					}
				}
			}
			if n, berr := repo.Index.Behind(l.Branch, l.Target); berr == nil {
				out.Behind = n
			}
			// Only an open record has a question to ask: a settled one
			// merged or did not, and probing it would spend a store read
			// to answer something history already settled.
			// A repository quarry can read but not shell into answers
			// nothing here, and unknown is the honest answer — never
			// "clean", which would offer accept on a branch nobody has
			// checked.
			if l.State == "open" && repo.Zit != nil {
				// Keyed on the two TIPS: mergeability is a function of
				// the check-ins, so the answer survives every view until
				// one of them moves, and a push invalidates it by
				// producing a different key rather than by anyone
				// remembering to clear a cache.
				//
				// The same two tips are what we PROBE (zit#134). Asking
				// about the pair skips the store walk `landing show`
				// needs to find the record we already folded, and it
				// removes the race in probing by branch name while
				// keying on a tip resolved a moment earlier.
				branchTip, targetTip := branchTipOf(repo, l.Branch), branchTipOf(repo, l.Target)
				if branchTip != "" && targetTip != "" {
					key := branchTip + ":" + targetTip
					// A rung above the bottom of a strata keeps "stacked":
					// the probe's answer is true and useless there, since
					// zit#144 refuses the accept whatever it says.
					if state, paths, merr := repo.Zit.MergeProbe(branchTip, targetTip, key); merr == nil && out.Mergeable != "stacked" {
						out.Mergeable, out.Conflicts = state, paths
					}
				}
			}
			// Without the CI letter the RUNS go — the rows, the logs, the
			// verdict badges. The policy stays: which checks trunk
			// requires and which are unmet is why the accept is closed,
			// and a reader who may not see the runs may still see why.
			if hideCI(ctx) {
				out.Checks = []CIRun{}
				out.MergeCI, out.MergeCIRun, out.Landing.CI, out.Stack.CI = "", "", "", ""
				for i := range out.Stack.Segments {
					out.Stack.Segments[i].CI = ""
				}
				// And SAY that they went. Required stays, as the comment
				// above explains; but Required beside an empty Checks is
				// the shape "nothing has run", and this reader is owed
				// the other reading.
				out.ChecksHidden = true
			}
			_, posts, found, err := repo.Index.ForumThreadPosts(l.ID)
			if err != nil {
				return landingGetOut{}, err
			}
			if !found {
				return out, nil
			}
			var hidden map[string]string
			if d.Forum != nil {
				if hidden, err = d.Forum.HiddenPosts(in.Repo); err != nil {
					return landingGetOut{}, err
				}
			}
			for i, p := range posts {
				lp := landingPost{forumRendered: forumRendered{Hash: p.Hash,
					Parent: p.Parent, Date: p.Date, User: p.User}, Root: i == 0}
				if state, ok := index.LandingTransition(p.Body); ok {
					lp.Transition = state
				}
				// A retarget is an event too (zit#144): it is machine
				// text, and rendering it as prose puts the marker itself
				// in the discussion.
				if to, ok := index.LandingRetarget(p.Body); ok {
					lp.Retarget = to
				}
				if m, ok := index.LandingUpdate(p.Body); ok {
					lp.Updated = m
				}
				if reason, ok := hidden[p.Hash]; ok {
					lp.Hidden, lp.HiddenReason = true, reason
					out.Posts = append(out.Posts, lp)
					continue
				}
				body := p.Body
				if lp.Root {
					// The opener's header is the record's encoding; what
					// a reader wants is the message under it.
					body = index.LandingProse(body)
					// A message written with no blank line has no prose
					// under the header at all: zit put the whole thing in
					// the title card. The record still HAS a message, so
					// the title is it — rendered here like any other
					// body, which is also the only way its markup ever
					// reaches a reader, since a heading is plain text.
					if strings.TrimSpace(body) == "" {
						body, lp.TitleIsBody = l.Title, true
					} else if !index.IsSubjectLine(l.Title) {
						// A first paragraph that runs to several
						// sentences is prose the author put before the
						// blank line, not a headline; drawn as an h1 it
						// is a wall. It leads the body instead, and the
						// page keeps its name from the sr-only heading.
						body, lp.TitleIsBody = l.Title+"\n\n"+body, true
					}
				}
				html, rerr := renderBody(repo.Index, in.Repo, landingMimetype(p.Mimetype), []byte(body))
				if rerr != nil {
					return landingGetOut{}, rerr
				}
				lp.HTML = html
				out.Posts = append(out.Posts, lp)
			}
			return out, 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.TicketByPrefixFor(in.ID, seesPrivate(ctx))
			if err != nil {
				return ticketGetOut{}, err
			}
			if !ok {
				if lout, lok, lerr := d.localTicketGet(repo, in.Repo, in.ID); lerr != nil || lok {
					return lout, lerr
				}
				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 := d.attachmentsOn(repo, in.Repo, t.ID)
			if err != nil {
				return ticketGetOut{}, err
			}
			out := ticketGetOut{Ticket: t, Attachments: att}
			out.ChatRefs = d.chatRefRowFor(in.Repo, t.ID)
			// What proposed to fix this, and how it went. Keyed off the
			// FULL id like the attachments above: the anchor index stores
			// prefixes, and matching them against a prefix the caller
			// happened to type would answer about the wrong ticket.
			if out.Mentions, err = repo.Index.MentionsOf(t.ID); err != nil {
				return ticketGetOut{}, err
			}
			if t.Description != "" {
				html, err := renderBodyOn(repo.Index, in.Repo, t.ID, t.DescriptionMimetype, []byte(t.Description))
				if err != nil {
					return ticketGetOut{}, err
				}
				out.DescriptionHTML = html
			}
			for _, c := range t.Comments {
				html, err := renderBodyOn(repo.Index, in.Repo, t.ID, 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.
			// attachment.list publishes a hash AND a src for every row,
			// and this route used to accept only the src: the hash —
			// the one a reader is most likely to copy, since it names
			// the attachment itself — answered "no attachment". Both
			// resolve now, and the src is what fetches the bytes.
			src := in.Hash
			name, ok, err := repo.Index.AttachmentName(src)
			if err != nil {
				return attachmentGetOut{}, err
			}
			if !ok {
				row, hit, herr := repo.Index.AttachmentByHash(in.Hash)
				if herr != nil {
					return attachmentGetOut{}, herr
				}
				if !hit {
					return attachmentGetOut{}, fmt.Errorf("no attachment %q: %w", in.Hash, ops.ErrNotFound)
				}
				src, name, ok = row.Src, row.Name, true
			}
			raw, err := repo.Zit.ArtifactRaw(ctx, src, maxAttachmentBytes)
			if err != nil {
				if errors.Is(err, zitcli.ErrNoEntry) {
					return attachmentGetOut{}, fmt.Errorf("attachment content unavailable: %w", ops.ErrNotFound)
				}
				return attachmentGetOut{}, err
			}
			return attachmentGetAnswer(src, name, raw)

		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[attachmentListIn, attachmentListOut]{
		Name: "attachment.list",
		// The same letter attachment.get carries. A target's own read
		// gate is the page it lives on: this op answers names, sizes and
		// hashes, and every one of those hashes still has to pass
		// AttachmentName before a single byte is served.
		Caps:  "jr",
		Doc:   "List the attachments live on one target — a ticket id, a wiki page name, a technote event id, or a forum post hash.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/attachments/{target}",
		Run: func(ctx context.Context, in attachmentListIn) (attachmentListOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return attachmentListOut{}, err
			}
			att, err := d.attachmentsOn(repo, in.Repo, in.Target)
			if err != nil {
				return attachmentListOut{}, err
			}
			if len(att) == 0 {
				if full := d.expandAttachTarget(ctx, repo, in.Target); full != in.Target {
					if att, err = d.attachmentsOn(repo, in.Repo, full); err != nil {
						return attachmentListOut{}, err
					}
				}
			}
			// An object with no files answers an empty list. Not found
			// would be a lie: the op cannot tell a bare ticket from a
			// target that never existed, and fossil's A-card log holds
			// no record of the difference.
			if att == nil {
				att = []index.Attachment{}
			}
			return attachmentListOut{Attachments: att}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[attachmentHeadIn, attachmentHeadOut]{
		Name: "attachment.head",
		// The same letter attachment.get carries, and the same gate: an
		// answer here says how big a file is and what shape it takes,
		// which is exactly what the page that may already download it
		// is allowed to know.
		Caps:  "jr",
		Doc:   "Describe one attachment without downloading it: its size, which of the four shapes it takes (image, text, media, other), and for text the first few lines.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/attachment/{hash}/head",
		Run: func(ctx context.Context, in attachmentHeadIn) (attachmentHeadOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return attachmentHeadOut{}, err
			}
			row, ok, err := repo.Index.AttachmentBySrc(in.Hash)
			if err != nil {
				return attachmentHeadOut{}, err
			}
			if !ok {
				return attachmentHeadOut{}, fmt.Errorf("no attachment %q: %w", in.Hash, ops.ErrNotFound)
			}
			name := row.Name
			// A stub is answered from its row and never from its bytes:
			// the fold already read the declaration, and the shape a
			// card takes is the declared type's (RFC 0016 §The stub).
			// The bucket decides whether those bytes exist; the page
			// can only say where they would be.
			if row.External {
				out := attachmentHeadOut{Name: name, Size: int(row.Size), Kind: attach.Kind(row.Mimetype),
					Mimetype: row.Mimetype, External: true, SHA256: row.SHA256,
					Held: d.withHolds(in.Repo, []index.Attachment{row})[0].Held}
				if d.Buckets == nil {
					out.Kind, out.Reason = attachKindMissing, "this forge binds no buckets"
				} else if info, _, err := d.bucketOf(in.Repo); err != nil || !info.Bound {
					out.Kind, out.Reason = attachKindMissing, "no bucket is bound to this repository"
				}
				return out, nil
			}
			// The memo first. A blob is immutable, so the verdict about
			// its bytes is too, and a page listing a dozen files should
			// cost a dozen row lookups rather than a dozen subprocesses.
			if h, hit, err := repo.Index.AttachmentHeadFor(in.Hash); err == nil && hit {
				return headOut(name, h, in.Lines), nil
			}
			size, err := repo.Zit.ArtifactSize(ctx, in.Hash)
			if err != nil {
				if errors.Is(err, zitcli.ErrNoEntry) {
					return attachmentHeadOut{}, fmt.Errorf("attachment content unavailable: %w", ops.ErrNotFound)
				}
				return attachmentHeadOut{}, err
			}
			// One byte past the bound, so a file that exactly fills it
			// is known to be whole rather than assumed truncated.
			prefix, err := repo.Zit.ArtifactHead(ctx, in.Hash, attachHeadPrefix+1)
			if err != nil {
				if errors.Is(err, zitcli.ErrNoEntry) {
					return attachmentHeadOut{}, fmt.Errorf("attachment content unavailable: %w", ops.ErrNotFound)
				}
				return attachmentHeadOut{}, err
			}
			h := attachmentHead(prefix, size, len(prefix) < size)
			// A memo that cannot be written is a slow page, never a
			// failed one: the answer is already in hand.
			if err := repo.Index.PutAttachmentHead(in.Hash, h); err != nil {
				log.Printf("api: attachment.head: memo %s: %v", in.Hash, err)
			}
			return headOut(name, h, in.Lines), nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[checkReportIn, checkReportOut]{
		Name:  "check.report",
		Doc:   "Report an external check's state onto a check-in — the commit-status contract, quarry-shaped (RFC 0009 §external-ci). Authenticated by a reporter token minted in the repository's CI settings; the token reaches this op and nothing else.",
		Kind:  ops.Command,
		Auth:  ops.Anonymous,
		Route: "POST /api/v1/r/{repo}/checks",
		Run: func(ctx context.Context, in checkReportIn) (checkReportOut, error) {
			if d.External == nil {
				return checkReportOut{}, fmt.Errorf("external checks are not configured: %w", ops.ErrUnavailable)
			}
			if !d.External.CheckReporterToken(in.Repo, in.Token) {
				return checkReportOut{}, fmt.Errorf("reporter token not recognised for %s: %w", in.Repo, ops.ErrForbidden)
			}
			if _, err := d.repo(in.Repo); err != nil {
				return checkReportOut{}, err
			}
			if err := d.External.UpsertExternalCheck(in.Repo, oltp.ExternalCheck{
				Subject: in.Checkin, Context: in.Context, State: in.State,
				URL: in.URL, Summary: in.Summary}); err != nil {
				return checkReportOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
			}
			return checkReportOut{OK: true, Context: in.Context, State: in.State}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ciTriggerIn, ciTriggerOut]{
		Name:  "ci.trigger",
		Caps:  "i",
		Doc:   "Run a repository's pipelines now, at a check-in or the tip (RFC 0009 §triggers). The re-run button; deploys and nightlies by hand.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/ci/trigger",
		Run: func(ctx context.Context, in ciTriggerIn) (ciTriggerOut, error) {
			if d.CITrigger == nil {
				return ciTriggerOut{}, fmt.Errorf("no CI engine configured: %w", ops.ErrUnavailable)
			}
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return ciTriggerOut{}, err
			}
			if err := d.CITrigger(in.Repo, in.Checkin); err != nil {
				return ciTriggerOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
			}
			return ciTriggerOut{OK: true}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ciTokenNewIn, ciTokenNewOut]{
		Name:  "ci.token.new",
		Caps:  "a",
		Doc:   "Mint a reporter token for external CI on one repository. The token is returned once and never stored; only its hash is kept.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/ci/tokens",
		Run: func(ctx context.Context, in ciTokenNewIn) (ciTokenNewOut, error) {
			if d.External == nil {
				return ciTokenNewOut{}, fmt.Errorf("external checks are not configured: %w", ops.ErrUnavailable)
			}
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return ciTokenNewOut{}, err
			}
			tok, err := d.External.MintReporterToken(in.Repo, in.Label)
			if err != nil {
				return ciTokenNewOut{}, err
			}
			return ciTokenNewOut{Token: tok}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ciTokenRevokeIn, ciTokenListOut]{
		Name:  "ci.token.revoke",
		Caps:  "a",
		Doc:   "Revoke one reporter token by its stored hash, returning the remaining list.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/ci/tokens/revoke",
		Run: func(ctx context.Context, in ciTokenRevokeIn) (ciTokenListOut, error) {
			if d.External == nil {
				return ciTokenListOut{}, fmt.Errorf("external checks are not configured: %w", ops.ErrUnavailable)
			}
			if err := d.External.RevokeReporterToken(in.Repo, in.Hash); err != nil {
				return ciTokenListOut{}, err
			}
			return d.reporterTokenList(in.Repo)
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ciTokenListIn, ciTokenListOut]{
		Name:  "ci.token.list",
		Caps:  "a",
		Doc:   "List a repository's reporter tokens: labels, creation times and hashes — never the tokens.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/r/{repo}/ci/tokens",
		Run: func(ctx context.Context, in ciTokenListIn) (ciTokenListOut, error) {
			if d.External == nil {
				return ciTokenListOut{}, nil
			}
			return d.reporterTokenList(in.Repo)
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ciListIn, ciListOut]{
		Name:  "ci.list",
		Caps:  "8",
		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) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return ciListOut{}, err
			}
			if d.CI == nil {
				return ciListOut{}, nil // no engine: an empty list, not an error
			}
			runs, err := d.CI.CIRunsPage(in.Repo, in.Limit, in.Offset)
			if err != nil {
				return ciListOut{}, err
			}
			// The check-in's own story rides on each row — one IN
			// query, and a hash the index has not folded yet simply
			// stays bare rather than failing the list.
			if repo.Index != nil {
				hashes := make([]string, 0, len(runs))
				for _, r := range runs {
					hashes = append(hashes, r.Checkin)
				}
				if meta, merr := repo.Index.CheckinsMeta(hashes); merr == nil {
					for i := range runs {
						if e, ok := meta[runs[i].Checkin]; ok {
							runs[i].Branch, runs[i].Comment, runs[i].User = e.Branch, e.Comment, e.User
						}
					}
				}
			}
			out := ciListOut{Runs: runs, Count: len(runs)}
			if d.Gate != nil {
				if bp, berr := d.Gate.GateBypasses(in.Repo, 5); berr == nil {
					out.Bypasses = bp
				}
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ciPipelineSaveIn, ciPipelineSaveOut]{
		Name: "ci.pipeline.save",
		// A pipeline is tracked content, so saving is a CHECK-IN — on a
		// fresh branch, with a landing opened for it, gated by the very
		// checks it defines. There is deliberately no way to write one
		// to trunk from here.
		Caps:  "i",
		Doc:   "Validate a pipeline, or commit it to a fresh branch and open a landing. Never writes to the target branch: a pipeline edit lands like any other change.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/ci/pipeline",
		Run: func(ctx context.Context, in ciPipelineSaveIn) (ciPipelineSaveOut, error) {
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return ciPipelineSaveOut{}, err
			}
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return ciPipelineSaveOut{}, err
			}
			if err := validatePipelinePath(in.Path); err != nil {
				return ciPipelineSaveOut{}, err
			}
			// Before anything is written OR validated: a spec naming
			// another repository's worker group would publish its steps
			// onto that repository's machines. Refused on the validate
			// path too, so the editor says so while the author is still
			// typing rather than at the moment they commit.
			if err := refuseForeignWorkerGroup(in.Repo, in.Content); err != nil {
				return ciPipelineSaveOut{}, err
			}
			diags := d.pipelineDiagnostics(in.Path, in.Content)
			if in.Validate || len(diags) > 0 {
				return ciPipelineSaveOut{Diagnostics: diags}, nil
			}
			if d.SavePipeline == nil {
				return ciPipelineSaveOut{}, fmt.Errorf("this forge cannot commit from the editor: %w", ops.ErrUnavailable)
			}
			user := ops.User(ctx)
			stem := strings.TrimSuffix(pathpkg.Base(in.Path), pathpkg.Ext(in.Path))
			var suffix [2]byte
			if _, err := rand.Read(suffix[:]); err != nil {
				return ciPipelineSaveOut{}, err
			}
			branch := "ci-edit-" + stem + "-" + hex.EncodeToString(suffix[:])
			message := strings.TrimSpace(in.Message)
			if message == "" {
				message = "ci: edit " + in.Path
			}
			hash, err := d.SavePipeline(in.Repo, branch, user, message, in.Path, []byte(in.Content))
			if err != nil {
				return ciPipelineSaveOut{}, err
			}
			out := ciPipelineSaveOut{Hash: hash, Branch: branch}
			if repo.Zit != nil {
				// zit wants the target said; the repository's main
				// branch is what an unnamed landing means everywhere.
				target := "trunk"
				if repo.DefaultBranch != nil {
					if name, _ := repo.DefaultBranch(); name != "" {
						target = name
					}
				}
				if err := repo.write(func() (werr error) {
					out.Landing, werr = repo.Zit.LandingOpen(user, branch, target,
						message+"\n\nProposed from the pipeline editor; it lands like any change, gated by the checks it defines.", authoredMimetype)
					return werr
				}); err != nil {
					// The commit stands; the record can be opened by
					// hand — but the miss is NAMED, not swallowed.
					log.Printf("ci: pipeline edit %s: landing not opened: %v", branch, err)
					return out, nil
				}
				_ = repo.synced()
			}
			return out, 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, archiveFile]{
		Name:  "archive.get",
		Caps:  "z",
		Doc:   "Download a check-in's files as a reproducible archive (tar.gz or zip). The answer is the archive itself, as bytes with its content type and an ETag naming the check-in — not a JSON envelope.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/archive/{version}",
		Run: func(ctx context.Context, in archiveIn) (archiveFile, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return archiveFile{}, err
			}
			format := in.Format
			if format == "" {
				format = "tar"
			}
			version := d.resolveVersion(repo, in.Version)
			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, ctype := ".tar.gz", "application/gzip"
			if format == "zip" {
				suffix, ctype = ".zip", "application/zip"
			}

			build := func(dst string) error {
				select {
				case archiveSlots <- struct{}{}:
					archiveCount(true)
					defer func() { <-archiveSlots }()
				case <-ctx.Done():
					archiveCount(false)
					return ctx.Err()
				case <-time.After(10 * time.Second):
					archiveCount(false)
					return fmt.Errorf("archive builds are busy: %w", ops.ErrUnavailable)
				}
				return d.Bulk.DoNamed(ctx, "archive", in.Repo, func() error {
					return repo.Zit.ArchiveTo(ctx, format, version, name, dst, maxArchiveBytes)
				})
			}
			// Nothing is built here. The answer names the bytes; Open
			// produces them, after the router has had its chance to say
			// 304. The cache keeps its file; a temporary one is removed
			// when the reader closes it.
			produce := func() (string, bool, error) {
				var path string
				var err error
				temp := d.Archives == nil
				if temp {
					path, err = tempArchive(build)
				} else {
					path, err = d.Archives.Get(archiveKey(in.Repo, d.ZitVersion, version, format), build)
				}
				if errors.Is(err, work.ErrBusy) {
					return "", false, fmt.Errorf("archive queue is full, retry shortly: %w", ops.ErrUnavailable)
				}
				if err != nil {
					if errors.Is(err, zitcli.ErrNoVersion) {
						return "", false, fmt.Errorf("no version %q in %s: %w", short, in.Repo, ops.ErrNotFound)
					}
					log.Printf("archive %s@%s (%s): %v", in.Repo, version, format, err)
					return "", false, fmt.Errorf("%w: %v", ops.ErrUnavailable, err)
				}
				return path, temp, nil
			}
			return archiveFile{name: name + suffix, ctype: ctype, version: version, format: format, build: produce}, 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.BranchesLimited(in.Limit, in.Open)
			if err != nil {
				return branchListOut{}, err
			}
			rows := d.withProtection(in.Repo, bs)
			return branchListOut{Branches: rows, Count: len(rows)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[branchAliasIn, branchAliasOut]{
		Name: "branch.alias",
		Caps: "o",
		Doc: "Say whether a branch name is another name for a branch that resolves to something else. " +
			"A fossil branch can carry several propagating symbolic names, and a rename in practice adds " +
			"one without cancelling the old, so both names keep resolving forever. A pushed check-in " +
			"resolves to exactly one of them, which is the name branch protection is matched against.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/branch/{branch}/alias",
		Run: func(ctx context.Context, in branchAliasIn) (branchAliasOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return branchAliasOut{}, err
			}
			alias, is, err := repo.Index.AliasOf(in.Branch)
			if err != nil {
				return branchAliasOut{}, err
			}
			return branchAliasOut{Branch: in.Branch, Alias: alias, IsAlias: is}, 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
				}
				res := docResolver{resolver{ix: repo.Index, repo: in.Repo}, version, docDirOf(e.Path)}
				html, err := renderBodyWith(res, 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",
		Scope: ops.ScopeCaller,
		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)
					p.Appearances = append(p.Appearances, appearance{
						Repo: name, Events: c.Events, First: c.First, Last: c.Last})
					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)
				// Busiest first: on a name shared by several people the
				// largest body of work is the one a reader most needs
				// to tell apart from the rest.
				sort.Slice(p.Appearances, func(i, j int) bool {
					return p.Appearances[i].Events > p.Appearances[j].Events
				})
				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",
		Scope: ops.ScopeCaller,
		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
			// A companion's hits are the PARENT's: "the companion is
			// not searchable on its own name" (RFC 0015 §Search), and a
			// reader who clicks a forum result lands on /r/X/forum
			// whichever store held the thread. Its facet folds into the
			// parent's too, or one repository would appear twice in the
			// list of where the answer is.
			under := func(store string) string {
				if parent := d.companionParent(store); parent != "" {
					return parent
				}
				return store
			}
			byRepo := map[string]int{}
			var facetOrder []string
			for _, a := range results {
				if a.repo == "" {
					continue
				}
				out.Searched++
				name := under(a.repo)
				if a.total > 0 {
					if _, seen := byRepo[name]; !seen {
						facetOrder = append(facetOrder, name)
					}
					byRepo[name] += a.total
				}
				for _, h := range a.hits {
					out.Results = append(out.Results, searchAllHit{Repo: name, SearchResult: h})
				}
			}
			for _, name := range facetOrder {
				out.ByRepo = append(out.ByRepo, repoCount{Repo: name, Count: byRepo[name]})
			}
			// 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]
			}
			// Decorated AFTER the trim. Every repository is asked for
			// the full limit so the merge can pick freely, so
			// decorating before it meant a per-result lookup for every
			// row gathered rather than every row shown — 25 indexes
			// times 50 rows to render 50.
			// Grouped by the POSITION each row came from, so the write
			// back is by index rather than by replaying the grouping
			// order — an ordering both loops happen to share is the
			// kind of agreement that holds until somebody sorts one of
			// them.
			at := map[string][]int{}
			for i, h := range out.Results {
				at[h.Repo] = append(at[h.Repo], i)
			}
			for name, idx := range at {
				repo, err := d.repo(name)
				if err != nil {
					continue
				}
				rows := make([]index.SearchResult, len(idx))
				for j, i := range idx {
					rows[j] = out.Results[i].SearchResult
				}
				if err := repo.Index.AttachSearchMeta(rows); err != nil {
					log.Printf("search.all meta %s: %v", name, err)
					continue
				}
				for j, i := range idx {
					out.Results[i].SearchResult = rows[j]
				}
			}
			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
			}
			// What tells two results apart, for the page being shown.
			if err := repo.Index.AttachSearchMeta(results); err != nil {
				return searchOut{}, err
			}
			// A bound forum lives in another store with its own index
			// (RFC 0015), so the parent's own index has never heard of
			// its threads. Searching fossil's forum for a word in 3,558
			// threads answered "nothing matches" — the store that could
			// answer was never asked.
			//
			// Merged rather than replaced: a repository bound after it
			// had threads of its own still has them, and both are on
			// the forum page a reader would click through to.
			comp, cerr := d.companionSearch(ctx, in)
			if cerr != nil {
				return searchOut{}, cerr
			}
			if len(comp) > 0 {
				results = mergeSearch(results, comp, in.Limit)
			}
			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 in.Branch {
				tip, ok, terr := repo.Index.TipOf(e.Branch)
				if terr != nil {
					return diffGetOut{}, terr
				}
				fork, ferr := repo.Index.ForkPoint(e.Branch)
				if ferr != nil {
					return diffGetOut{}, ferr
				}
				if !ok || fork == "" {
					// A branch with no fork point is the repository's
					// own root line: there is no "before the branch" to
					// diff against. Say so rather than silently falling
					// back to the parent diff, which would answer a
					// different question under the same link.
					return diffGetOut{}, fmt.Errorf("branch %q has no fork point to diff from: %w", e.Branch, ops.ErrNotFound)
				}
				e.Hash, in.From = tip.Tip, fork
				out.To = tip.Tip
			}
			if in.From != "" {
				base, _, found, berr := repo.Index.CheckinByPrefix(in.From)
				if berr != nil {
					return diffGetOut{}, berr
				}
				if !found {
					return diffGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.From, ops.ErrNotFound)
				}
				parents = []string{base.Hash}
			}
			// A diff is the store's to compute. An index without a store
			// behind it — a harness, or a repository mid-registration —
			// can list check-ins but cannot diff them, and asking would
			// dereference nothing. Checked here, after the lookups, so an
			// unknown check-in is still "not found" and not "no store".
			if repo.Zit == nil {
				return diffGetOut{}, fmt.Errorf("no store to diff %q: %w", in.Repo, ops.ErrUnavailable)
			}
			if len(parents) == 0 {
				// A root check-in has nothing to diff against; its
				// whole file set is the change.
				// Names only: each entry becomes DiffEntry{Status, Name}.
				files, err := repo.Zit.LsNames(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.Files = brief
			if in.Unified {
				out.Unified = unified
			}
			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 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)
			// Names only: the doc index is a set of paths.
			files, err := repo.Zit.LsNames(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}, version, docDirOf(path)},
				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.
				lines := in.Lines
				if lines == 0 {
					lines = defaultFileWindow
				}
				hl, total, ok := highlight.Window(raw, "", in.Path, in.From, lines)
				switch {
				case ok:
					out.HTML = markup.Sanitize(hl)
					out.From = in.From
					out.TotalLines = total
					if total == 0 {
						// The window stopped before the end, so there
						// is more to ask for.
						out.NextFrom = in.From + lines
					}
				case highlight.TooBig(raw):
					// Say why the colour is missing. Silently serving a
					// large file as grey text reads as a broken
					// highlighter rather than a deliberate limit.
					out.Plain = "too large to highlight"
				}
			} 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 := attachCI(ctx, repo, entries); err != nil {
				return checkinGetOut{}, err
			}
			out := checkinGetOut{
				Hash: e.Hash, CI: entries[0].CI, Branch: e.Branch, Date: e.Date,
				// The DECORATED comment, not the raw C card: a later
				// tag artifact may have corrected it, and the
				// correction is what a reader should be shown.
				User: e.User, Comment: entries[0].Comment, Parents: parents,
				CommentOrig: entries[0].CommentOrig,
				Hidden:      entries[0].Hidden, Closed: entries[0].Closed,
			}
			// The run behind the verdict, newest first, so the badge can
			// be followed. Best-effort: the verdict comes from the store
			// and must still answer with the engine down.
			if d.CI != nil {
				if runs, rerr := d.CI.CIRunsFor(in.Repo, e.Hash); rerr == nil && len(runs) > 0 {
					out.CIRun = runs[0].RunID
				}
			}
			out.ChatRefs = d.chatRefRowFor(in.Repo, e.Hash)
			if out.DiscussedIn, err = repo.Index.ThreadsMentioning(e.Hash); err != nil {
				return checkinGetOut{}, err
			}
			if out.Tags, err = repo.Index.TagsOf(e.Hash); err != nil {
				return checkinGetOut{}, err
			}
			if out.Fork, err = repo.Index.ForkPoint(e.Branch); err != nil {
				return checkinGetOut{}, err
			}
			// Cheap: two bounded index walks, no subprocess.
			if out.Context, err = repo.Index.CheckinContext(e.Hash, checkinContextSpan); 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[manifestGetIn, manifestGetOut]{
		Name:  "checkin.manifest",
		Caps:  "o",
		Doc:   "One check-in's manifest artifact, as cards. The file list is counted, not returned — it is 99% of the bytes and the tree browser already renders it.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/checkin/{hash}/manifest",
		Run: func(ctx context.Context, in manifestGetIn) (manifestGetOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return manifestGetOut{}, err
			}
			e, _, ok, err := repo.Index.CheckinByPrefix(in.Hash)
			if err != nil {
				return manifestGetOut{}, err
			}
			if !ok {
				return manifestGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound)
			}
			if repo.Zit == nil {
				return manifestGetOut{}, fmt.Errorf("no store for %q: %w", in.Repo, ops.ErrNotFound)
			}
			// The STORE is the authority on what an artifact holds
			// (0001 §2) — the index answers which check-in, never what
			// bytes it is made of. So this is a store read, and the
			// only page in the tree that needs one for a check-in.
			raw, err := repo.Zit.ArtifactRaw(ctx, e.Hash, manifestViewMax)
			if err != nil {
				return manifestGetOut{}, err
			}
			out := manifestGetOut{Hash: e.Hash, Size: len(raw)}
			for _, line := range strings.Split(strings.TrimRight(string(raw), "\n"), "\n") {
				if line == "" {
					continue
				}
				letter := line[:1]
				if letter == "F" {
					out.Files++
					continue
				}
				out.Cards = append(out.Cards, ManifestCard{
					Letter: letter, Text: strings.TrimSpace(line[1:]),
					Note: cardNote[letter],
				})
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[checkinCloseIn, checkinCloseOut]{
		Name: "checkin.close",
		Caps: "i",
		Doc: "Close a leaf, or reopen one: records fossil's `closed` tag on the check-in. " +
			"Closing says a line of work has ended and hides the leaf from the open set. " +
			"On a PROTECTED branch it takes an owner's hand, both ways: a closed leaf refuses " +
			"its own branch's next check-in, so closing one is a change to the branch and not " +
			"only to the check-in.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/checkin/{hash}/close",
		Run: func(ctx context.Context, in checkinCloseIn) (checkinCloseOut, error) {
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return checkinCloseOut{}, err
			}
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return checkinCloseOut{}, err
			}
			// Resolved to the full hash first: a tag artifact names one
			// object exactly, and a prefix that matches two check-ins
			// must be refused rather than closing whichever came first.
			e, _, ok, err := repo.Index.CheckinByPrefix(in.Hash)
			if err != nil {
				return checkinCloseOut{}, err
			}
			if !ok {
				return checkinCloseOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound)
			}
			// RFC 0007: closing a leaf ends a line of work ON A BRANCH,
			// and on a protected branch that is its owners' call.
			//
			// The tag asks for `i` and nothing else, which is exactly
			// what every account the protection exists to constrain
			// already holds -- protection limits people who can check
			// in, and checking in is what writes the tag. So the hole
			// was the width of the control's whole audience rather than
			// its edge, and reversibility did not mitigate it: the
			// owner's symptom is their own commit failing with "cannot
			// commit against a closed leaf", which names neither the
			// cause nor the actor (ad732c8f).
			//
			// Reopen takes the same decision. A non-owner who could
			// reopen could undo an owner's close, which is the same
			// membership question wearing the opposite sign.
			if d.Protect != nil && !d.Protect.MayJoin(in.Repo, ops.User(ctx), e.Branch) {
				_, owners := d.protectionOf(in.Repo, e.Branch)
				return checkinCloseOut{}, fmt.Errorf("%s is protected; owners: %s: %w",
					e.Branch, strings.Join(owners, ", "), ops.ErrForbidden)
			}
			// The tag is the same either way; what differs is the SIGN
			// the store records, which is a cancel rather than a name
			// with a minus in front of it.
			write := func() error { return repo.WriteTag("closed", e.Hash, "") }
			if in.Reopen {
				write = func() error { return repo.CancelTag("closed", e.Hash) }
			}
			if err := write(); err != nil {
				return checkinCloseOut{}, err
			}
			return checkinCloseOut{Hash: e.Hash, Closed: !in.Reopen}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[checkinTagsIn, checkinTagsOut]{
		Name:  "checkin.tags",
		Caps:  "o",
		Doc:   "The full tag history of a check-in — every tag artifact that touched it, oldest first, with which rows still stand.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/checkin/{hash}/tags",
		Run: func(ctx context.Context, in checkinTagsIn) (checkinTagsOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return checkinTagsOut{}, err
			}
			e, _, ok, err := repo.Index.CheckinByPrefix(in.Hash)
			if err != nil {
				return checkinTagsOut{}, err
			}
			if !ok {
				return checkinTagsOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound)
			}
			entries := []index.TimelineEntry{e}
			if err := attachCI(ctx, repo, entries); err != nil {
				return checkinTagsOut{}, err
			}
			out := checkinTagsOut{
				Hash: e.Hash, Date: e.Date, User: e.User,
				Comment: entries[0].Comment, CommentOrig: entries[0].CommentOrig,
				Branch: e.Branch, Hidden: entries[0].Hidden, Closed: entries[0].Closed,
			}
			if out.Events, err = repo.Index.TagHistory(e.Hash); err != nil {
				return checkinTagsOut{}, err
			}
			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)
			}
			// The page NAME, not the artifact hash: fossil targets a
			// wiki attachment by the name of the page it is on, so the
			// files follow the page across every edit.
			att, err := d.attachmentsOn(repo, in.Repo, page.Title)
			if err != nil {
				return wikiGetOut{}, err
			}
			html, err := renderBodyOn(repo.Index, in.Repo, page.Title, 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, Attachments: att,
				// 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; rev selects one edit.",
		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.TechnoteRevision(in.Event, in.Rev)
			if err != nil {
				return technoteGetOut{}, err
			}
			if !ok {
				return technoteGetOut{}, fmt.Errorf("no technote %q: %w", in.Event, ops.ErrNotFound)
			}
			att, err := d.attachmentsOn(repo, in.Repo, n.Event)
			if err != nil {
				return technoteGetOut{}, err
			}
			html, err := renderBodyOn(repo.Index, in.Repo, n.Event, n.Mimetype, []byte(n.Body))
			if err != nil {
				return technoteGetOut{}, err
			}
			// The history is read for the count either way: it is a
			// covered walk of one event id, and a page that knows there
			// are eight versions can say so without a second request.
			hist, err := repo.Index.TechnoteHistory(n.Event)
			if err != nil {
				return technoteGetOut{}, err
			}
			out := technoteGetOut{Event: n.Event, Hash: n.Hash, Date: n.Date, User: n.User,
				Comment: n.Comment, Body: n.Body, HTML: html, Revisions: len(hist),
				Attachments: att}
			if len(hist) > 0 {
				out.Edited = hist[0].Edited
				for _, rev := range hist {
					if rev.Hash == n.Hash {
						out.Edited = rev.Edited
						break
					}
				}
				out.Historical = hist[0].Hash != n.Hash
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[technoteHistoryIn, technoteHistoryOut]{
		Name:  "technote.history",
		Caps:  "j",
		Doc:   "List every recorded edit of one technote, newest first.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/technote/{event}/history",
		Run: func(ctx context.Context, in technoteHistoryIn) (technoteHistoryOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return technoteHistoryOut{}, err
			}
			// Resolve the prefix FIRST. TechnoteHistory takes an exact
			// id by design, so that a short prefix matching two notes
			// cannot silently interleave their histories.
			n, ok, err := repo.Index.TechnoteByEvent(in.Event)
			if err != nil {
				return technoteHistoryOut{}, err
			}
			if !ok {
				return technoteHistoryOut{}, fmt.Errorf("no technote %q: %w", in.Event, ops.ErrNotFound)
			}
			edits, err := repo.Index.TechnoteHistory(n.Event)
			if err != nil {
				return technoteHistoryOut{}, err
			}
			return technoteHistoryOut{Event: n.Event, Comment: n.Comment,
				Edits: edits, Count: len(edits)}, 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.forumRepoFor(ctx, in.Repo)
			if err != nil {
				return forumListOut{}, err
			}
			total, err := repo.Index.ForumThreadCount()
			if err != nil {
				return forumListOut{}, err
			}
			// The hot sort ranks across the whole forum before it can
			// say what the first page is, so it reads every thread and
			// pages afterwards. Recency is the index's own order, so it
			// pages in SQL and reads only what it shows.
			limit, before := in.Limit, in.Before
			if in.Sort == "hot" {
				limit, before = 0, ""
			}
			// One more than the page, so "is there a next page" is
			// answered by what came back rather than by a second query
			// that could disagree with it.
			if limit > 0 {
				limit++
			}
			threads, err := repo.Index.ForumThreads(limit, before)
			if err != nil {
				return forumListOut{}, err
			}
			out := forumListOut{Total: total}
			// Answer the poster's question before they post it. The
			// forum's state, and whether it applies to THIS caller —
			// the gate exempts the moderation letters, so a moderator
			// reading this correctly sees a moderated forum that will
			// not hold them.
			if m := forumModerationFor(d, in.Repo, ops.User(ctx)); m.moderated {
				out.Moderated, out.YourPostsHeld = m.moderated, m.yours
			}
			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
			var read map[string]bool
			if d.Forum != nil {
				if cats, err = d.Forum.ThreadCategories(in.Repo); err != nil {
					return forumListOut{}, err
				}
				// Read-state is the caller's own, so an anonymous list
				// comes back with none — which is the honest answer, not
				// a degraded one.
				last := make(map[string]string, len(threads))
				for _, t := range threads {
					last[t.Root] = t.Updated
				}
				if read, err = d.Forum.ForumRead(in.Repo, ops.User(ctx), last); err != nil {
					return forumListOut{}, err
				}
			}
			for _, t := range threads {
				row := forumThreadOut{ForumThread: t, Score: scores[t.Root],
					Category: cats[t.Root], Read: read[t.Root]}
				row.Spark = sparkBins(t.Dates, 12)
				row.Dates = nil // the sparkline is the shape; the raw series stays home
				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())
				// Ranked, then cut: an offset into a ranking is the
				// only cursor a ranking can have, and the tracker's own
				// listing already accepts that trade for its sorted
				// columns.
				if in.Offset > 0 {
					if in.Offset >= len(out.Threads) {
						out.Threads = nil
					} else {
						out.Threads = out.Threads[in.Offset:]
					}
				}
				if in.Limit > 0 && len(out.Threads) > in.Limit {
					out.Threads = out.Threads[:in.Limit]
					out.Next = strconv.Itoa(in.Offset + in.Limit)
				}
			} else if in.Limit > 0 && len(out.Threads) > in.Limit {
				out.Threads = out.Threads[:in.Limit]
				out.Next = index.ForumCursor(out.Threads[len(out.Threads)-1].ForumThread)
			}
			out.Count = len(out.Threads)
			// The viewer's OWN submissions a moderator has not released:
			// waiting, or refused and never announced.
			// Added last and to the viewer alone: a staged post is not in
			// the DAG, so nothing above this line can have found it, and
			// its author had no way to learn it existed (G-118).
			if who := ops.User(ctx); who != "" && d.Forum != nil {
				held, err := d.Forum.StagedMine(in.Repo, who)
				if err != nil {
					return forumListOut{}, err
				}
				for _, h := range held {
					row := forumHeldOut{ID: h.ID, Kind: h.Kind, Root: h.Root,
						Title: h.Title, At: h.At, State: h.State}
					if h.State == "rejected" {
						row.Reason, row.Body = h.Reason, h.Body
					}
					out.Held = append(out.Held, row)
				}
			}
			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.forumRepoFor(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}
			out.ChatRefs = d.chatRefRowFor(in.Repo, in.Root)
			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 := renderBodyOn(repo.Index, in.Repo, p.Hash, p.Mimetype, []byte(body))
				if err != nil {
					return forumGetOut{}, err
				}
				// The post's own hash is the A card's target for a forum
				// attachment, and repo here is the COMPANION the thread
				// lives in (RFC 0015) — the same index that answered for
				// the posts, never the parent's.
				att, err := d.attachmentsOn(repo, in.Repo, p.Hash)
				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,
					Edits: p.Edits, Edited: p.Edited,
					Score: scores[p.Hash], YourVote: mine[p.Hash], Poll: poll,
					Hidden: isHidden, HiddenReason: reason, Attachments: att,
				})
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := registerAuthoring(r, d); err != nil {
		return err
	}
	// ref.resolve and people.mention are not chat ops: a reference in a
	// forum post or a ticket resolves the same way, on a forge with no
	// chat plane at all.
	if err := registerRefResolve(r, d); err != nil {
		return err
	}
	if err := registerPeopleSuggest(r, d); err != nil {
		return err
	}
	if err := registerPeopleMention(r, d); err != nil {
		return err
	}
	// The person-addressed plane is registered unconditionally: a forge
	// with no attention plane still documents the surface and answers it
	// honestly — an empty stream, a zero count — which is what keeps
	// docs/api.md one document across every deployment.
	if err := registerAttention(r, d); err != nil {
		return err
	}
	if err := registerInbox(r, d); err != nil {
		return err
	}
	if err := registerCIGate(r, d); err != nil {
		return err
	}
	if err := registerForumHistory(r, d); err != nil {
		return err
	}
	if err := registerBeta(r, d); err != nil {
		return err
	}
	if err := registerAccountEmail(r, d); err != nil {
		return err
	}
	if err := registerChangelog(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. `attach` lists deferred upload tickets to commit against the new post; refusals come back in `attachErrors`.",
		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
			}
			if err := checkAttachList(in.Attach); err != nil {
				return forumPostOut{}, err
			}
			repo, err := d.forumRepoFor(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, AttachErrors: stagedAttachNote(in.Attach)}, 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":    d.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, AttachErrors: d.attachTickets(ctx, repo, in.Repo, hash, in.Attach)}, 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. `attach` lists deferred upload tickets to commit against the reply; refusals come back in `attachErrors`.",
		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
			}
			if err := checkAttachList(in.Attach); err != nil {
				return forumReplyOut{}, err
			}
			repo, err := d.forumRepoFor(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, AttachErrors: stagedAttachNote(in.Attach)}, 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":    d.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, AttachErrors: d.attachTickets(ctx, repo, in.Repo, hash, in.Attach)}, 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,
			// The 5 letter (ModForum) is the category's who-may-post
			// policy, enforced by Caps: announcement-as-a-type, not a
			// flag an ordinary post could set. Replies stay open to
			// everyone through forum.reply. Gated on the letter alone,
			// not site admin — moderating a repository's own forum is
			// that repository's own business (docs/hosting.md).
			Auth:  ops.Token,
			Route: "POST /api/v1/r/{repo}/forum-announce",
			Run: func(ctx context.Context, in forumPostIn) (forumPostOut, error) {
				repo, err := d.forumRepoFor(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":    d.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.forumRepoFor(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 err := registerCompanions(r, d); err != nil {
			return err
		}
		if err := registerRead(r, d); err != nil {
			return err
		}
	}
	if d.Stamp != nil {
		if err := ops.Register(r, ops.Op[stampSetIn, stampSetOut]{
			Name: "stamp.set",
			// No capability letter. Stamp is the one forge act open to
			// every signed-in account on every repository it can see:
			// the read gate has already decided whether this repository
			// exists for this caller, and past that gate praising is not
			// a privilege anyone needs granting.
			Doc:   "Stamp a repository, or remove the stamp. Any signed-in account.",
			Kind:  ops.Command,
			Auth:  ops.Token,
			Route: "POST /api/v1/r/{repo}/stamp",
			Run: func(ctx context.Context, in stampSetIn) (stampSetOut, error) {
				// repoFor is the visibility gate: a repository the caller
				// may not read must be indistinguishable from one that
				// does not exist, stamp included.
				if _, err := d.repoFor(ctx, in.Repo); err != nil {
					return stampSetOut{}, err
				}
				account := ops.User(ctx)
				if account == "" {
					return stampSetOut{}, fmt.Errorf("stamp needs a signed-in account: %w", ops.ErrInvalid)
				}
				on, err := d.Stamp.SetStamp(in.Repo, account, in.On)
				if err != nil {
					return stampSetOut{}, err
				}
				kind := evt.StampRemoved
				if on {
					kind = evt.StampAdded
				}
				d.fireNotify(NotifyEvent{
					Kind:     kind,
					Workflow: "quarry-activity", To: "repo:" + in.Repo,
					Payload: map[string]any{"repo": in.Repo, "actor": account,
						"summary": "stamped " + in.Repo,
						"link":    d.link("/r/" + in.Repo)},
					TxnID: "activity:stamp:" + in.Repo + ":" + account,
				})
				return stampSetOut{Stamped: on, Count: d.Stamp.StampCount(in.Repo)}, nil
			},
		}); err != nil {
			return err
		}
		if err := ops.Register(r, ops.Op[stampListIn, stampListOut]{
			Name:  "stamp.list",
			Doc:   "List the repositories an account stamps that the caller may read.",
			Kind:  ops.Query,
			Route: "GET /api/v1/stamp",
			Run: func(ctx context.Context, in stampListIn) (stampListOut, error) {
				who := in.User
				if who == "" {
					who = ops.User(ctx)
				}
				if who == "" {
					return stampListOut{}, fmt.Errorf("stamp.list needs a user: %w", ops.ErrInvalid)
				}
				all, err := d.Stamp.StampedBy(who)
				if err != nil {
					return stampListOut{}, err
				}
				// Filtered by the VIEWER, never by the owner. Stamp is
				// public; the existence of a private repository is not,
				// and this is where the two meet. Somebody else's list
				// and your own are different lists of the same stamp.
				out := make([]string, 0, len(all))
				for _, repo := range all {
					if d.CanRead == nil || d.CanRead(ctx, repo) {
						out = append(out, repo)
					}
				}
				return stampListOut{Repos: out}, nil
			},
		}); 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.forumRepoFor(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, when := in.Event, normalizeEventTime(in.Time)
			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
				if when == "" {
					when = n.Date // keep it where the author filed it
				}
			}
			var id, hash string
			if err := repo.write(func() (err error) {
				id, hash, err = repo.Zit.TechnoteAdd(author(ctx), event, mt, in.Comment, when, []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":    d.link("/r/" + in.Repo + "/technote/" + id[:min(16, len(id))])},
				TxnID: "activity:technote:" + hash,
			})
			return technoteAddOut{Event: id, Hash: hash}, nil
		},
	})
}

// normalizeEventTime pads a minute-precision timestamp with seconds.
// An <input type="datetime-local"> submits "2026-07-14T09:30" and zit
// stores the E card verbatim, which leaves one card in the repository
// shaped unlike every other — fossil writes seconds — and every date
// helper that parses it falls back to printing it raw.
func normalizeEventTime(t string) string {
	if len(t) == 16 && t[10] == 'T' {
		return t + ":00"
	}
	return t
}

// registerChat wires the per-repo live channel ops (0001 §5, RFC 0011
// §1). Called from RegisterAll only when a chat plane is configured.
func registerChat(r *ops.Registry, d Deps) error {
	regs := []func() error{
		func() error { return registerChatHistory(r, d) },
		func() error { return registerChatPost(r, d) },
		func() error { return registerChatEdit(r, d) },
		func() error { return registerChatDelete(r, d) },
		func() error { return registerChatPurge(r, d) },
		func() error { return registerChatPreview(r, d) },
		func() error { return registerChatRead(r, d) },
		func() error { return registerChatUnread(r, d) },
		func() error { return registerChatGet(r, d) },
		func() error { return registerChatStream(r, d) },
		func() error { return registerChatRobot(r, d) },
	}
	for _, reg := range regs {
		if err := reg(); err != nil {
			return err
		}
	}
	return nil
}

func registerChatHistory(r *ops.Registry, d Deps) error {
	return 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
			}
			var msgs []chat.Message
			var next uint64
			if in.After > 0 {
				// Forward paging. The older-page cursor is this
				// page's own oldest seq, exactly as History computes
				// it: before is exclusive, so the page below this one
				// ends where this one begins.
				fw, _, ferr := d.Chat.Forward(repo.Code, in.After, limit)
				if ferr != nil {
					return chatHistoryOut{}, ferr
				}
				msgs = fw
				if len(fw) > 0 {
					if first, _, berr := d.Chat.Bounds(repo.Code); berr == nil && fw[0].Seq > first {
						next = fw[0].Seq
					}
				}
			} else {
				var herr error
				msgs, next, herr = d.Chat.History(repo.Code, in.Before, limit)
				if herr != nil {
					return chatHistoryOut{}, herr
				}
			}
			// Tombstones never surface (Fold drops them); an edit or
			// delete referencing a message OUTSIDE this page is a known
			// limitation the RFC records (Drawbacks: "a naive consumer
			// of chat.stream sees deleted messages" — paging shares the
			// same edge, bounded by one window).
			folded := chat.Fold(msgs)
			rendered := d.chatRenderAll(ctx, repo, in.Repo, folded)
			chatQuote(rendered)
			out := chatHistoryOut{Messages: rendered, Count: len(folded), Next: next}
			// Whether a NEWER page exists is read by LOOKING, not from
			// the channel's tip: the tip is frequently an edit or a
			// delete tombstone, which folds away, so the newest message
			// on the newest page always sat "below" it and every reader
			// at the end saw a "later messages" control leading to
			// nothing. One short forward probe, folded, is the truth.
			if len(folded) > 0 {
				newest := folded[len(folded)-1].Seq
				if fw, _, ferr := d.Chat.Forward(repo.Code, newest, 8); ferr == nil && len(chat.Fold(fw)) > 0 {
					out.Later = newest
				}
			}
			if who := author(ctx); who != "" && d.Forum != nil {
				out.Read = d.Forum.ChatReadSeq(in.Repo, who)
			}
			return out, nil
		},
	})
}

// chatMentionAt fires mentions and reference-independent notification
// for a text carrying a chat seq (RFC 0011 §4). Idempotency keys are
// address-based on the seq, which — unlike Phase 0's nanosecond
// timestamp — makes the "same post replayed triggers nothing new"
// proof hold on chat too: a replayed post shares its seq (JetStream
// itself is the identity), so a replay of the same publish keeps the
// same TxnID.
func (d Deps) chatMentionAt(repo, text, from string, seq uint64) {
	for _, who := range mentionsIn(text, d.mentionKnown(repo)) {
		ref := fmt.Sprintf("chat:%d", seq)
		if d.Forum != nil {
			if err := d.Forum.RecordMention(repo, ref, who, from); err != nil {
				log.Printf("mention: record %s at %s: %v", who, ref, err)
			}
		}
		d.fireNotify(NotifyEvent{
			Workflow: "quarry-mention", To: who,
			Payload: map[string]any{
				"repo": repo, "from": from, "text": text,
				"link": fmt.Sprintf("/r/%s/chat/%d", repo, seq),
			},
			TxnID: fmt.Sprintf("mention:chat:%s:%d:%s", repo, seq, who),
		})
	}
}

// recordChatRefs writes one backlink row per ARTIFACT the message
// named, then prunes what the window has dropped. Chat and person
// references are not artifacts and get no row: a backlink row exists to
// put a "chat" line on an artifact's page, and neither of those has one.
//
// Each row is keyed by the artifact's OWN name, never by the prefix the
// author happened to type: pages look their rows up by full hash, so a
// row filed under `a91f3c2e` would sit where nothing ever looks.
func (d Deps) recordChatRefs(ctx context.Context, repo *Repo, repoName, text string, seq uint64) {
	if d.ChatRefs == nil {
		return
	}
	res := d.refTokenResolver(ctx, repo.Index, repoName)
	at := time.Now().UTC().Format(time.RFC3339Nano)
	for _, t := range markup.RefsIn(text, res) {
		// Only this repository's artifacts: the row is keyed by repo,
		// and a page in another repository is that repository's page.
		if t.Repo != "" && t.Repo != repoName {
			continue
		}
		// The name RefsIn's own pass already resolved — a bare hash's
		// kind included — not a second lookup that could answer
		// differently.
		target, ok := res.canonical(t)
		if !ok {
			continue
		}
		if err := d.ChatRefs.RecordChatRef(repoName, target, seq, at); err != nil {
			log.Printf("chat ref: record %s at %d: %v", target, seq, err)
		}
	}
	d.pruneChatWindow(repo.Code, repoName)
}

// pruneChatWindow drops the backlink and reply rows the channel window
// no longer holds. It runs on the post path AND on the sweep in
// cmd/quarry: a busy repository prunes as it goes, and a quiet one —
// whose window still ages out on the stream's own retention — does not
// keep rows for messages nobody can read any more.
func (d Deps) pruneChatWindow(code, repoName string) {
	if d.ChatRefs == nil || d.Chat == nil {
		return
	}
	first, _, err := d.Chat.Bounds(code)
	if err != nil {
		return
	}
	if err := d.ChatRefs.PruneChatRefs(repoName, first); err != nil {
		log.Printf("chat ref: prune %s: %v", repoName, err)
	}
	if err := d.ChatRefs.PruneChatReplies(repoName, first); err != nil {
		log.Printf("chat reply: prune %s: %v", repoName, err)
	}
}

// ChatWindowSweep prunes every registered repository's chat rows once.
// The window moves with the stream's retention, not only with a post,
// so a repository nobody has spoken in still needs the sweep.
func (d Deps) ChatWindowSweep() {
	if d.ChatRefs == nil || d.Chat == nil || d.Repos == nil {
		return
	}
	for _, name := range d.Repos.Names() {
		repo, ok := d.Repos.Get(name)
		if !ok {
			continue
		}
		d.pruneChatWindow(repo.Code, name)
	}
}

// ChatReplyBackfill writes the reply rows a window predating them never
// got. chat.get and the preview card count from chat_reply; the log
// counts by folding — so a reply posted before the rows shipped was
// one number on the log and another on the permalink and the card. One
// idempotent pass over the live window at startup ends that: every
// visible reply the window still holds gets its row, and INSERT OR
// IGNORE makes a second pass a no-op. A deleted reply gets none, the
// same rule chat.delete applies when it drops one.
func (d Deps) ChatReplyBackfill() {
	if d.ChatRefs == nil || d.Chat == nil || d.Repos == nil {
		return
	}
	for _, name := range d.Repos.Names() {
		repo, ok := d.Repos.Get(name)
		if !ok {
			continue
		}
		first, _, err := d.Chat.Bounds(repo.Code)
		if err != nil {
			continue
		}
		if first == 0 {
			// Nobody has ever posted here. Bounds says so with a zero
			// first sequence, and scanning an empty channel is a round
			// trip that can only return nothing.
			continue
		}
		msgs, err := d.Chat.Range(repo.Code, first, 0)
		if err != nil {
			log.Printf("chat reply backfill: %s: %v", name, err)
			// Journalled, not just logged. This one failed 1,308 times
			// in a day while the Exceptions panel said the forge was
			// healthy: a background job is exactly the thing nobody is
			// watching the log for.
			errlog.Default.ReportIn("chat", "chat reply backfill", name, "",
				fault.Wrap(err, "chat reply threading could not be backfilled").
					Cost("older replies in this channel render flat instead of threaded; no message is lost").
					With("repo", name))
			continue
		}
		for _, m := range chat.Fold(msgs) {
			if m.Ref == 0 || m.Kind != chat.KindMsg || m.Deleted {
				continue
			}
			if err := d.ChatRefs.RecordChatReply(name, m.Ref, m.Seq); err != nil {
				log.Printf("chat reply backfill: %s %d: %v", name, m.Seq, err)
			}
		}
	}
}

func registerChatPost(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[chatPostIn, chatPostOut]{
		Name: "chat.post",
		// Chat is capability-gated exactly like reading it (RFC 0011
		// §8). Auth moved from Anonymous to Token here: anonymous
		// writing was never a feature, it was mentions firing for
		// "anonymous" (RFC 0012 Phase 0).
		Caps: "C",
		Doc:  "Post a message to a repository's chat channel.",
		Kind: ops.Command,
		Auth: ops.Token,
		// A chat post mutates no store (0001 §5: transport is NATS, not
		// the DAG), so it is not lease-gated.
		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
			}
			if in.Ref != 0 {
				existing, err := d.Chat.Range(repo.Code, in.Ref, in.Ref)
				if err != nil {
					return chatPostOut{}, err
				}
				if len(existing) == 0 {
					return chatPostOut{}, fmt.Errorf("no message %d to reply to: %w", in.Ref, ops.ErrInvalid)
				}
			}
			from := author(ctx)
			seq, err := d.Chat.Post(repo.Code, chat.Message{
				Repo: in.Repo, User: from, Text: in.Text, Ref: in.Ref, Kind: chat.KindMsg,
			})
			if err != nil {
				return chatPostOut{}, err
			}
			d.chatMentionAt(in.Repo, in.Text, from, seq)
			if in.Ref != 0 && d.ChatRefs != nil {
				if err := d.ChatRefs.RecordChatReply(in.Repo, in.Ref, seq); err != nil {
					log.Printf("chat reply: record %d->%d: %v", in.Ref, seq, err)
				}
			}
			d.recordChatRefs(ctx, repo, in.Repo, in.Text, seq)
			return chatPostOut{OK: true, Seq: seq}, nil
		},
	})
}

// chatOwnMessage fetches one message by seq, the shared lookup behind
// edit, delete and reply-existence checks — a bounded forward scan
// (RFC 0011 §2), not a second index.
func (d Deps) chatOwnMessage(code string, seq uint64) (chat.Message, error) {
	msgs, err := d.Chat.Range(code, seq, seq)
	if err != nil {
		return chat.Message{}, err
	}
	if len(msgs) == 0 {
		return chat.Message{}, fmt.Errorf("no message %d: %w", seq, ops.ErrNotFound)
	}
	return msgs[0], nil
}

// chatDeleted answers whether a del tombstone already names seq — the
// forward scan from the message itself, since a tombstone is always
// published after what it deletes.
func (d Deps) chatDeleted(code string, seq uint64) (bool, error) {
	tomb, err := d.chatTombstone(code, seq)
	return tomb != 0, err
}

// chatTombstone is the same scan, answering WHICH sequence marks seq
// deleted (0 when none does). Purge needs the number, not the fact:
// removing a message from the timeline means removing its mark too.
func (d Deps) chatTombstone(code string, seq uint64) (uint64, error) {
	rest, err := d.Chat.Range(code, seq, 0)
	if err != nil {
		return 0, err
	}
	for _, m := range rest {
		if m.Kind == chat.KindDel && m.Ref == seq {
			return m.Seq, nil
		}
	}
	return 0, nil
}

func registerChatEdit(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[chatEditIn, chatEditOut]{
		Name:  "chat.edit",
		Caps:  "C",
		Doc:   "Edit a chat message posted within the last 5 minutes, own only.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/chat/{seq}/edit",
		Run: func(ctx context.Context, in chatEditIn) (chatEditOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return chatEditOut{}, err
			}
			orig, err := d.chatOwnMessage(repo.Code, in.Seq)
			if err != nil {
				return chatEditOut{}, err
			}
			who := author(ctx)
			if orig.Kind != chat.KindMsg || orig.User != who {
				return chatEditOut{}, fmt.Errorf("not your message: %w", ops.ErrForbidden)
			}
			// The window first: it is a clock comparison, and the
			// tombstone check below scans the channel from this seq to
			// the tip, which an ancient message should never pay for.
			posted, perr := time.Parse(time.RFC3339Nano, orig.At)
			if perr != nil || time.Since(posted) > chatEditWindow {
				return chatEditOut{}, fmt.Errorf("edit window elapsed: %w", ops.ErrForbidden)
			}
			// A delete is terminal. Editing a message someone (its
			// author, or a moderator) has already deleted would put the
			// text back on the wire for every consumer that folds
			// naively, so the tombstone wins here rather than in the
			// renderer alone.
			deleted, derr := d.chatDeleted(repo.Code, in.Seq)
			if derr != nil {
				return chatEditOut{}, derr
			}
			if deleted {
				return chatEditOut{}, fmt.Errorf("message %d is deleted: %w", in.Seq, ops.ErrInvalid)
			}
			seq, err := d.Chat.Post(repo.Code, chat.Message{
				Repo: in.Repo, User: who, Kind: chat.KindEdit, Ref: in.Seq, Text: in.Text,
			})
			if err != nil {
				return chatEditOut{}, err
			}
			return chatEditOut{OK: true, Seq: seq}, nil
		},
	})
}

func registerChatDelete(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[chatDeleteIn, chatDeleteOut]{
		Name:  "chat.delete",
		Caps:  "C",
		Doc:   "Delete a chat message: own with C, any message with a.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/chat/{seq}/delete",
		Run: func(ctx context.Context, in chatDeleteIn) (chatDeleteOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return chatDeleteOut{}, err
			}
			orig, err := d.chatOwnMessage(repo.Code, in.Seq)
			if err != nil {
				return chatDeleteOut{}, err
			}
			who := author(ctx)
			if orig.Kind != chat.KindMsg {
				return chatDeleteOut{}, fmt.Errorf("not a message: %w", ops.ErrInvalid)
			}
			if orig.User != who && !ops.CapsOf(ctx).HasAny("a") {
				return chatDeleteOut{}, fmt.Errorf("not your message: %w", ops.ErrForbidden)
			}
			seq, err := d.Chat.Post(repo.Code, chat.Message{
				Repo: in.Repo, User: who, Kind: chat.KindDel, Ref: in.Seq,
			})
			if err != nil {
				return chatDeleteOut{}, err
			}
			// A deleted message is not a reply anybody can see, so it
			// stops counting as one — or the card and the folded log
			// would disagree about the same message.
			if d.ChatRefs != nil {
				if err := d.ChatRefs.DropChatReply(in.Repo, in.Seq); err != nil {
					log.Printf("chat reply: drop %d: %v", in.Seq, err)
				}
			}
			return chatDeleteOut{OK: true, Seq: seq}, nil
		},
	})
}

// chatPurgeIn/Out: the second step of removal. Seq names the ORIGINAL
// message — the one that was deleted — not its tombstone, because that
// is the number the reader can see and the number the permalink used.
type chatPurgeIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	Seq  uint64 `json:"seq" validate:"required"`
}

type chatPurgeOut struct {
	OK bool `json:"ok"`
	// Purged is how many stream sequences were erased: the message and
	// its tombstone, or fewer if the window had already swept one.
	Purged int `json:"purged"`
}

func registerChatPurge(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[chatPurgeIn, chatPurgeOut]{
		Name: "chat.purge",
		Caps: "C",
		Doc: "Remove an already-deleted message from the timeline: erase the message and its " +
			"tombstone from the stream. Own with C, any with a. Irreversible.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/chat/{seq}/purge",
		Run: func(ctx context.Context, in chatPurgeIn) (chatPurgeOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return chatPurgeOut{}, err
			}
			orig, err := d.chatOwnMessage(repo.Code, in.Seq)
			if err != nil {
				return chatPurgeOut{}, err
			}
			if orig.Kind != chat.KindMsg {
				return chatPurgeOut{}, fmt.Errorf("not a message: %w", ops.ErrInvalid)
			}
			who := author(ctx)
			if orig.User != who && !ops.CapsOf(ctx).HasAny("a") {
				return chatPurgeOut{}, fmt.Errorf("not your message: %w", ops.ErrForbidden)
			}
			// Deleted FIRST. Purge is the second step of a two-step
			// removal, never a one-click erase: the tombstone is what
			// told the room something went, and skipping it would let
			// a message vanish with no mark at all.
			tomb, err := d.chatTombstone(repo.Code, in.Seq)
			if err != nil {
				return chatPurgeOut{}, err
			}
			if tomb == 0 {
				return chatPurgeOut{}, fmt.Errorf("delete it first: %w", ops.ErrInvalid)
			}
			if err := d.Chat.Purge(repo.Code, in.Seq, tomb); err != nil {
				return chatPurgeOut{}, err
			}
			// The reply rows go with it: a reference to a sequence the
			// stream no longer holds resolves to nothing, and a count
			// that includes it is a count of something invisible.
			if d.ChatRefs != nil {
				if err := d.ChatRefs.DropChatReply(in.Repo, in.Seq); err != nil {
					log.Printf("chat reply: drop %d: %v", in.Seq, err)
				}
			}
			return chatPurgeOut{OK: true, Purged: 2}, nil
		},
	})
}

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

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

func registerChatPreview(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[chatPreviewIn, chatPreviewOut]{
		Name: "chat.preview",
		Caps: "C",
		Doc: "Render a draft message exactly as posting it would — same markdown, same " +
			"mentions, same reference resolution. A preview from any other renderer is a guess.",
		// Command by TRANSPORT, not by effect: it mutates nothing, but
		// a draft is a body — up to 4KB — and the registry is right
		// that a query with a body is a POST wearing the wrong verb.
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/chat/preview",
		Run: func(ctx context.Context, in chatPreviewIn) (chatPreviewOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return chatPreviewOut{}, err
			}
			m := d.chatRenderOne(ctx, repo, in.Repo, chat.Message{
				Kind: chat.KindMsg, User: author(ctx), Text: in.Text,
			})
			return chatPreviewOut{HTML: m.HTML}, nil
		},
	})
}

type ciGateAckIn struct {
	Repo string `json:"repo" validate:"required,max=64"`
	// Checkin names one finding; All clears every open one.
	Checkin string `json:"checkin" validate:"max=64"`
	All     bool   `json:"all"`
}

type ciGateAckOut struct {
	Acked int `json:"acked"`
}

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

type ciGateListOut struct {
	Findings []oltp.GateBypass `json:"findings"`
	Count    int               `json:"count"`
	Total    int               `json:"total"`
}

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

type forumHistoryOut struct {
	Root     string          `json:"root"`
	Title    string          `json:"title"`
	Versions []forumRendered `json:"versions"`
}

// registerForumHistory answers what a thread page folds away: every
// version of an edited post. Editing writes a new artifact whose P card
// names the old one; the thread shows the newest, this shows them all.
func registerForumHistory(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[forumHistoryIn, forumHistoryOut]{
		Name:  "forum.history",
		Caps:  "2",
		Doc:   "Every version of one forum post, oldest first — the edits a thread page folds into its newest version.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/forum/{root}/history/{post}",
		Run: func(ctx context.Context, in forumHistoryIn) (forumHistoryOut, error) {
			repo, err := d.forumRepoFor(ctx, in.Repo)
			if err != nil {
				return forumHistoryOut{}, err
			}
			title, _, ok, err := repo.Index.ForumThreadPosts(in.Root)
			if err != nil {
				return forumHistoryOut{}, err
			}
			if !ok {
				return forumHistoryOut{}, fmt.Errorf("no forum thread %q: %w", in.Root, ops.ErrNotFound)
			}
			versions, ok, err := repo.Index.ForumPostVersions(in.Post)
			if err != nil {
				return forumHistoryOut{}, err
			}
			if !ok {
				return forumHistoryOut{}, fmt.Errorf("no forum post %q: %w", in.Post, ops.ErrNotFound)
			}
			out := forumHistoryOut{Root: in.Root, Title: title}
			for _, p := range versions {
				body := p.Body
				if _, rest := parsePoll(body); rest != body {
					body = rest
				}
				html, err := renderBody(repo.Index, in.Repo, p.Mimetype, []byte(body))
				if err != nil {
					return forumHistoryOut{}, err
				}
				out.Versions = append(out.Versions, forumRendered{Hash: p.Hash, Parent: p.Parent,
					Date: p.Date, User: p.User, HTML: html})
			}
			return out, nil
		},
	})
}

// MailPlane sends the emails that cannot ride the notify plane: an
// invite goes to a person who has no subscriber yet. nil means the
// forge has no mail, and surfaces say so instead of pretending.
type MailPlane interface {
	Send(ctx context.Context, to, subject, text, html string) (id string, err error)
}

// AccountAdmin is the private-beta half of the account seam: invites,
// the lockout, and the session audit. Optional — an AccountStore that
// does not implement it has none of those surfaces.
type AccountAdmin interface {
	// InviteAccount creates a passwordless account (or re-invites a
	// pending one) and returns the one-time token for its link.
	InviteAccount(name, email string, admin bool) (token string, err error)
	// InviteFor names the account behind a live token.
	InviteFor(token string) (name string, ok bool)
	// RedeemInvite sets the password and spends the token.
	RedeemInvite(token, password string) (name string, err error)
	// ResetPassword mints a one-time token letting an EXISTING account
	// choose a new password. The old one keeps working until it is
	// spent, and minting supersedes any unspent token.
	ResetPassword(name string) (token string, err error)
	// TokenKind says whether a live token is an "invite" or a "reset".
	TokenKind(token string) string
	// SetDisabled shuts or reopens an account; shutting ends its sessions.
	SetDisabled(name string, on bool) error
	// Sessions lists live sessions — one account's, or all when name is "".
	Sessions(name string) ([]SessionInfo, error)
	// EndSession ends one; a non-empty account limits it to that account's own.
	EndSession(id, account string) error
	// EndSessions signs an account out everywhere.
	EndSessions(account string) error
}

// SessionInfo is one signed-in device.
type SessionInfo struct {
	ID       string `json:"id"`
	Account  string `json:"account"`
	Created  string `json:"created"`
	Expires  string `json:"expires"`
	LastSeen string `json:"lastSeen"`
	IP       string `json:"ip"`
	Agent    string `json:"agent"`
}

type accountInviteIn struct {
	Name  string `json:"name" validate:"required,max=64"`
	Email string `json:"email" validate:"max=200"`
	Admin bool   `json:"admin"`
	// Base is the forge's origin, for the link; the web passes its own.
	Base string `json:"base" validate:"max=200"`
}

type accountInviteOut struct {
	Name string `json:"name"`
	// Link is the one-time invite URL. Shown to the admin whatever
	// happened with email, so a beta can run before mail is set up.
	Link    string `json:"link"`
	Emailed bool   `json:"emailed"`
	// MailError says why the email did not go, when it did not.
	MailError string `json:"mailError,omitempty"`
}

type accountAgentIn struct {
	Name string `json:"name" validate:"required,max=64"`
	// Operator is the account answerable for this one; empty clears
	// the mark and makes it a person's account again.
	Operator string `json:"operator" validate:"max=64"`
}

type accountResetIn struct {
	Name string `json:"name" validate:"required,max=64"`
	// Base is the forge's origin, for the link; the web passes its own.
	Base string `json:"base" validate:"max=200"`
}

type accountPeekIn struct {
	Token string `json:"token" validate:"required,min=32,max=128"`
}

type accountClaimIn struct {
	Token    string `json:"token" validate:"required,min=32,max=128"`
	Password string `json:"password" validate:"required,min=8,max=200"`
}

type accountClaimOut struct {
	Name string `json:"name"`
	// Kind is "invite" or "reset". The page that opens the link
	// greets two different people: one finishing setting up, one
	// who has forgotten a password they already had.
	Kind string `json:"kind,omitempty"`
}

type accountDisableIn struct {
	Name string `json:"name" validate:"required,max=64"`
	// On shuts the account; false reopens it.
	On bool `json:"on"`
}

type sessionListIn struct {
	// Account limits the list; admins may name anyone, everyone else
	// gets their own regardless.
	Account string `json:"account" validate:"max=64"`
}

type sessionListOut struct {
	Sessions []SessionInfo `json:"sessions"`
	Count    int           `json:"count"`
}

type sessionEndIn struct {
	// ID names one session; Account instead signs that account out
	// everywhere (admins; anyone may name themself).
	ID      string `json:"id" validate:"max=64"`
	Account string `json:"account" validate:"max=64"`
}

func (d Deps) accountAdmin() (AccountAdmin, error) {
	if d.Accounts == nil {
		return nil, errNoAccounts()
	}
	aa, ok := d.Accounts.(AccountAdmin)
	if !ok {
		return nil, fmt.Errorf("this forge's account store has no invites: %w", ops.ErrUnavailable)
	}
	return aa, nil
}

type changelogOut struct {
	// Version is the build serving this answer.
	Version string `json:"version"`
	HTML    string `json:"html"`
	// Entries are the "## " headings, newest first: what an
	// announcement names and what the page lists.
	Entries []string `json:"entries"`
}

type changelogAnnounceIn struct {
	// Base is the forge's origin for the link; the web passes its own.
	Base string `json:"base" validate:"max=200"`
}

type changelogAnnounceOut struct {
	Heading    string `json:"heading"`
	Recipients int    `json:"recipients"`
}

// changelogEntries is every second-level heading of the changelog,
// newest first — the file is written newest first, as changelogs are.
func changelogEntries(md []byte) []string {
	var out []string
	for _, line := range strings.Split(string(md), "\n") {
		if strings.HasPrefix(line, "## ") {
			out = append(out, strings.TrimSpace(strings.TrimPrefix(line, "## ")))
		}
	}
	return out
}

// registerChangelog is the beta's news desk: a rendered CHANGELOG.md and
// a way to tell every account that a new entry exists. Nothing announces
// itself — an admin says so, once, on purpose.
func registerChangelog(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[struct{}, changelogOut]{
		Name:  "changelog.get",
		Doc:   "The forge's changelog, rendered, with its entry headings newest first and the build serving it.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/changelog",
		Run: func(ctx context.Context, _ struct{}) (changelogOut, error) {
			if d.Changelog == nil {
				return changelogOut{}, fmt.Errorf("this forge keeps no changelog: %w", ops.ErrNotFound)
			}
			md, err := d.Changelog(ctx)
			if err != nil {
				return changelogOut{}, fmt.Errorf("changelog: %w", ops.ErrNotFound)
			}
			html, err := renderBodyWith(nil, "text/x-markdown", md)
			if err != nil {
				return changelogOut{}, err
			}
			return changelogOut{Version: d.Version, HTML: html, Entries: changelogEntries(md)}, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[changelogAnnounceIn, changelogAnnounceOut]{
		Name:  "changelog.announce",
		Caps:  "s",
		Scope: ops.ScopeFleet,
		Doc:   "Tell every account about the newest changelog entry: an inbox item and a push through the notify plane, naming the entry and linking the page. Idempotent per entry heading.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/changelog/announce",
		Run: func(ctx context.Context, in changelogAnnounceIn) (changelogAnnounceOut, error) {
			if d.Changelog == nil {
				return changelogAnnounceOut{}, fmt.Errorf("this forge keeps no changelog: %w", ops.ErrNotFound)
			}
			md, err := d.Changelog(ctx)
			if err != nil {
				return changelogAnnounceOut{}, fmt.Errorf("changelog: %w", ops.ErrNotFound)
			}
			entries := changelogEntries(md)
			if len(entries) == 0 {
				return changelogAnnounceOut{}, fmt.Errorf("the changelog has no entries to announce: %w", ops.ErrInvalid)
			}
			if d.Notify == nil {
				return changelogAnnounceOut{}, fmt.Errorf("this forge has no notify plane: %w", ops.ErrUnavailable)
			}
			heading := entries[0]
			// Everyone with an account hears it: the "changelog" topic
			// is re-filled from the account list each time, so a person
			// invited yesterday is on it today.
			n := 0
			if d.Accounts != nil && d.Provision != nil {
				accts, err := d.Accounts.Accounts()
				if err != nil {
					return changelogAnnounceOut{}, err
				}
				for _, a := range accts {
					if a.Disabled != "" || a.Pending {
						continue
					}
					if err := d.Provision.SetTopicMember(ctx, "changelog", a.Name, true); err == nil {
						n++
					}
				}
			}
			sum := sha256.Sum256([]byte(heading))
			link := d.base(in.Base) + "/changelog"
			if err := d.Notify.Trigger(ctx, NotifyEvent{Kind: evt.Kind("changelog.announced"), Workflow: "quarry-changelog",
				To: "repo:changelog", TxnID: "changelog:" + hex.EncodeToString(sum[:8]),
				Payload: map[string]any{"title": heading, "link": link, "from": ops.User(ctx)}}); err != nil {
				return changelogAnnounceOut{}, err
			}
			return changelogAnnounceOut{Heading: heading, Recipients: n}, nil
		},
	})
}

// registerBeta is the private-beta machinery: invite by email, claim
// the invite, shut an account, and see who is signed in from where.
func registerBeta(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[accountInviteIn, accountInviteOut]{
		Name:  "account.invite",
		Caps:  "s",
		Scope: ops.ScopeFleet,
		Doc:   "Invite a person: create their account with no password and mint a one-time link that lets them set one. Sent to the email through notify when it is set up; returned either way so it can be passed on by hand.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/accounts/invite",
		Run: func(ctx context.Context, in accountInviteIn) (accountInviteOut, error) {
			aa, err := d.accountAdmin()
			if err != nil {
				return accountInviteOut{}, err
			}
			if in.Email != "" && !strings.Contains(in.Email, "@") {
				return accountInviteOut{}, fmt.Errorf("email must be an address: %w", ops.ErrInvalid)
			}
			token, err := aa.InviteAccount(in.Name, in.Email, in.Admin)
			if err != nil {
				return accountInviteOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
			}
			link := d.base(in.Base) + "/invite/" + token
			out := accountInviteOut{Name: in.Name, Link: link}
			// Emailed means DELIVERED TO THE PROVIDER, never "asked
			// somebody to". The mail plane answers that; the notify
			// plane only records the event, so it does not count.
			if in.Email != "" && d.Mail != nil {
				from := ops.User(ctx)
				subject := from + " invited you to quarry"
				text := "You have been invited to quarry as " + in.Name + ".\n\n" +
					"Set your password and sign in here:\n" + link + "\n\n" +
					"The link works once and for seven days. If you were not expecting this, ignore it."
				html := "<p>You have been invited to <strong>quarry</strong> as <strong>" + html.EscapeString(in.Name) + "</strong>.</p>" +
					"<p><a href=\"" + html.EscapeString(link) + "\">Set your password and sign in</a></p>" +
					"<p style=\"color:#666\">The link works once and for seven days. If you were not expecting this, ignore it.</p>"
				if _, err := d.Mail.Send(ctx, in.Email, subject, text, html); err != nil {
					out.MailError = err.Error()
				} else {
					out.Emailed = true
				}
			}
			// The invite is sent ONCE, by quarry's own mailer above.
			// It used to also trigger notify's invite workflow, which
			// is email-only: the moment an email integration exists on
			// the companion, the same person receives two invitations
			// from the same address with different words. quarry holds
			// the mail credential and the invite copy, and an invitee
			// has no account for any other channel to reach, so there
			// is nothing for the companion to add here.
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[accountAgentIn, accountNameOut]{
		Name: "account.agent",
		// No cap letter: the gate here is not about a repository. It is
		// about WHO is asking, and it is deliberately asymmetric.
		Doc: "Mark an account as machine-operated and name the person answerable for it; an empty operator clears the mark. " +
			"An account may always declare ITSELF an agent. Only a site admin may clear the mark or mark somebody else, " +
			"because declaring is never the dangerous direction and hiding always is.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/accounts/agent",
		Run: func(ctx context.Context, in accountAgentIn) (accountNameOut, error) {
			if d.Accounts == nil {
				return accountNameOut{}, errNoAccounts()
			}
			who := ops.User(ctx)
			admin := d.isSiteAdmin(who)
			// Declaring yourself: allowed, and the operator must be
			// somebody who can be asked. Naming anybody else as the
			// answerable party is a claim about them, so it needs an
			// admin the same as marking somebody else's account does.
			self := who != "" && who == in.Name
			switch {
			case admin:
			case self && in.Operator != "" && in.Operator == who:
			case self && in.Operator != "":
				return accountNameOut{}, fmt.Errorf(
					"name yourself as the operator, or ask a site admin to name %s: %w", in.Operator, ops.ErrForbidden)
			case self:
				return accountNameOut{}, fmt.Errorf(
					"only a site admin may clear an agent mark: %w", ops.ErrForbidden)
			default:
				return accountNameOut{}, fmt.Errorf(
					"only a site admin may mark another account: %w", ops.ErrForbidden)
			}
			if err := d.Accounts.SetOperator(in.Name, in.Operator); err != nil {
				return accountNameOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
			}
			return accountNameOut{Name: in.Name}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[accountResetIn, accountInviteOut]{
		Name: "account.reset",
		// Another account's password, not the caller's — so the subject
		// is the forge and not ScopeCaller, and the letter is gone
		// because Auth: ops.Admin already refuses everybody a
		// repository could not grant it to. The letter never fired:
		// the floor refuses first, which is why this read as an admin
		// route all along (7ddd1768).
		Scope: ops.ScopeFleet,
		Doc: "Reset a forgotten password: mint a one-time link that lets the account choose a new one itself. " +
			"The current password keeps working until the link is spent, and minting supersedes any unspent link. " +
			"Sent to the account's address through notify when it has one; returned either way so it can be passed on by hand.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/accounts/reset",
		Run: func(ctx context.Context, in accountResetIn) (accountInviteOut, error) {
			aa, err := d.accountAdmin()
			if err != nil {
				return accountInviteOut{}, err
			}
			token, err := aa.ResetPassword(in.Name)
			if err != nil {
				return accountInviteOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
			}
			link := d.base(in.Base) + "/invite/" + token
			out := accountInviteOut{Name: in.Name, Link: link}
			// The address on the ACCOUNT, never one the caller passes:
			// a reset that could be posted to an address supplied with
			// the request would be a way to take somebody's account.
			to := ""
			if d.Accounts != nil {
				if list, err := d.Accounts.Accounts(); err == nil {
					for _, a := range list {
						if a.Name == in.Name {
							to = a.Email
							break
						}
					}
				}
			}
			if to != "" && d.Mail != nil {
				subject := "Set a new password for quarry"
				text := "Somebody asked to reset the password for " + in.Name + " on quarry.\n\n" +
					"Choose a new one here:\n" + link + "\n\n" +
					"The link works once and for an hour. Your current password keeps working until you use it, " +
					"so if you did not ask for this you can ignore it."
				html := "<p>Somebody asked to reset the password for <strong>" + html.EscapeString(in.Name) + "</strong> on quarry.</p>" +
					"<p><a href=\"" + html.EscapeString(link) + "\">Choose a new password</a></p>" +
					"<p style=\"color:#666\">The link works once and for an hour. Your current password keeps working " +
					"until you use it, so if you did not ask for this you can ignore it.</p>"
				if _, err := d.Mail.Send(ctx, to, subject, text, html); err != nil {
					out.MailError = err.Error()
				} else {
					out.Emailed = true
				}
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[accountPeekIn, accountClaimOut]{
		Name:  "account.peek",
		Doc:   "Name the account behind a live invite token, so the invite page can greet its owner. Says nothing about spent or unknown tokens beyond not found.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/accounts/invite/{token}",
		Run: func(ctx context.Context, in accountPeekIn) (accountClaimOut, error) {
			aa, err := d.accountAdmin()
			if err != nil {
				return accountClaimOut{}, err
			}
			name, ok := aa.InviteFor(in.Token)
			if !ok {
				return accountClaimOut{}, fmt.Errorf("%w: this invite is not valid any more", ops.ErrNotFound)
			}
			return accountClaimOut{Name: name, Kind: aa.TokenKind(in.Token)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[accountClaimIn, accountClaimOut]{
		Name:  "account.claim",
		Doc:   "Redeem an invite: set the account's password from its one-time link. The link is spent; sign in as usual after this.",
		Kind:  ops.Command,
		Auth:  ops.Anonymous,
		Route: "POST /api/v1/accounts/claim",
		Run: func(ctx context.Context, in accountClaimIn) (accountClaimOut, error) {
			aa, err := d.accountAdmin()
			if err != nil {
				return accountClaimOut{}, err
			}
			name, err := aa.RedeemInvite(in.Token, in.Password)
			if err != nil {
				return accountClaimOut{}, fmt.Errorf("%w: this invite is not valid any more", ops.ErrNotFound)
			}
			return accountClaimOut{Name: name}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[accountDisableIn, struct{}]{
		Name:  "account.disable",
		Caps:  "s",
		Scope: ops.ScopeFleet,
		Doc:   "Shut an account (on=true) or reopen it. Shutting ends every session it holds at once; the account, its grants and its history stay.",
		Kind:  ops.Command,
		Auth:  ops.Admin,
		Route: "POST /api/v1/accounts/disable",
		Run: func(ctx context.Context, in accountDisableIn) (struct{}, error) {
			aa, err := d.accountAdmin()
			if err != nil {
				return struct{}{}, err
			}
			if in.On && in.Name == ops.User(ctx) {
				return struct{}{}, fmt.Errorf("you cannot shut your own account: %w", ops.ErrInvalid)
			}
			if err := aa.SetDisabled(in.Name, in.On); err != nil {
				return struct{}{}, fmt.Errorf("%w: %v", ops.ErrNotFound, err)
			}
			return struct{}{}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[sessionListIn, sessionListOut]{
		Name:  "session.list",
		Doc:   "Who is signed in, from where, since when. An admin sees every account's sessions (or one account's with account=); everyone else sees their own.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/sessions",
		Run: func(ctx context.Context, in sessionListIn) (sessionListOut, error) {
			aa, err := d.accountAdmin()
			if err != nil {
				return sessionListOut{}, err
			}
			who := in.Account
			// Setup capability (s) is what a site admin holds everywhere;
			// anyone else is confined to their own rows.
			if !ops.CapsOf(ctx).HasAny("s") {
				who = ops.User(ctx)
			}
			ss, err := aa.Sessions(who)
			if err != nil {
				return sessionListOut{}, err
			}
			return sessionListOut{Sessions: ss, Count: len(ss)}, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[sessionEndIn, struct{}]{
		Name:  "session.end",
		Doc:   "Sign out one session by its id — any session for an admin, only your own otherwise — or every session of an account with account=.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/sessions/end",
		Run: func(ctx context.Context, in sessionEndIn) (struct{}, error) {
			aa, err := d.accountAdmin()
			if err != nil {
				return struct{}{}, err
			}
			owner := ops.User(ctx)
			if ops.CapsOf(ctx).HasAny("s") {
				owner = ""
			}
			if in.Account != "" {
				if owner != "" && in.Account != owner {
					return struct{}{}, fmt.Errorf("only your own sessions: %w", ops.ErrInvalid)
				}
				return struct{}{}, aa.EndSessions(in.Account)
			}
			if len(in.ID) < 12 {
				return struct{}{}, fmt.Errorf("name a session id or an account: %w", ops.ErrInvalid)
			}
			return struct{}{}, aa.EndSession(in.ID, owner)
		},
	})
}

func registerCIGate(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[ciGateAckIn, ciGateAckOut]{
		Name: "ci.gate.ack",
		Caps: "a",
		Doc: "Acknowledge gate-bypass findings — check-ins that moved a protected branch without " +
			"a forge accept. Acknowledging hides a finding from the CI page's strip; the record stays " +
			"in the history. One check-in, or all=true for every open finding.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/ci/gate/ack",
		Run: func(ctx context.Context, in ciGateAckIn) (ciGateAckOut, error) {
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return ciGateAckOut{}, err
			}
			if d.Gate == nil {
				return ciGateAckOut{}, fmt.Errorf("no gate store: %w", ops.ErrInvalid)
			}
			if in.All {
				n, err := d.Gate.AckAllGateBypasses(in.Repo)
				return ciGateAckOut{Acked: n}, err
			}
			if in.Checkin == "" {
				return ciGateAckOut{}, fmt.Errorf("name a checkin or say all: %w", ops.ErrInvalid)
			}
			if err := d.Gate.AckGateBypass(in.Repo, in.Checkin); err != nil {
				return ciGateAckOut{}, err
			}
			return ciGateAckOut{Acked: 1}, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[ciGateListIn, ciGateListOut]{
		Name:  "ci.gate.list",
		Caps:  "8",
		Doc:   "Every gate-bypass finding for a repository, open or acknowledged, newest first, paged.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/ci/gate",
		Run: func(ctx context.Context, in ciGateListIn) (ciGateListOut, error) {
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return ciGateListOut{}, err
			}
			if d.Gate == nil {
				return ciGateListOut{}, nil
			}
			rows, total, err := d.Gate.GateBypassHistory(in.Repo, in.Offset, in.Limit)
			if err != nil {
				return ciGateListOut{}, err
			}
			return ciGateListOut{Findings: rows, Count: len(rows), Total: total}, nil
		},
	})
}

func registerChatRead(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[chatReadIn, chatReadOut]{
		Name:  "chat.read",
		Caps:  "C",
		Doc:   "Mark a repository's chat channel read up to seq (default: latest).",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/chat/read",
		Run: func(ctx context.Context, in chatReadIn) (chatReadOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return chatReadOut{}, err
			}
			seq := in.Seq
			if seq == 0 {
				_, last, err := d.Chat.Bounds(repo.Code)
				if err != nil {
					return chatReadOut{}, err
				}
				seq = last
			}
			if d.Forum != nil {
				if err := d.Forum.MarkChatRead(in.Repo, author(ctx), seq); err != nil {
					return chatReadOut{}, err
				}
			}
			return chatReadOut{OK: true, Seq: seq}, nil
		},
	})
}

func registerChatUnread(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[chatUnreadIn, chatUnreadOut]{
		Name: "chat.unread",
		// No Caps: this surface names no single repository — it is
		// "for me, across every repo I can read" — so the router's
		// one-repo capability check (which resolves against path/form
		// field "repo") would refuse every real caller by checking
		// their caps on "" instead. The Run body does the per-repo
		// check itself, with CanRead, the same way inbox.* answers "for
		// the person, not a repository" (RFC 0011 §8).
		Doc:   "Per-repository unread chat counts for the signed-in account.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/me/chat/unread",
		Run: func(ctx context.Context, in chatUnreadIn) (chatUnreadOut, error) {
			who := author(ctx)
			out := chatUnreadOut{Repos: map[string]chatUnreadRepo{}}
			if who == "" || d.Repos == nil {
				return out, nil
			}
			var marks map[string]uint64
			if d.Forum != nil {
				var err error
				marks, err = d.Forum.ChatReadAll(who)
				if err != nil {
					return out, err
				}
			}
			for _, name := range d.Repos.Names() {
				if d.CanRead != nil && !d.CanRead(ctx, name) {
					continue
				}
				// Per-repo "C", since the router had nothing to check
				// (chat.unread names no single repo).
				if d.CapsFor != nil && !d.CapsFor(who, name).HasAny("C") {
					continue
				}
				repo, err := d.repoFor(ctx, name)
				if err != nil {
					continue
				}
				since := marks[name]
				// The channel's own count, from a subject-filtered
				// view: last-since would be the stream's arithmetic,
				// and the stream is every repository at once.
				unread, err := d.Chat.Unread(repo.Code, since)
				if err != nil {
					// A broker error is not "nothing unread"; a badge
					// that hides outages teaches people to ignore it.
					return chatUnreadOut{}, fmt.Errorf("chat unread %s: %w", name, err)
				}
				if unread <= 0 {
					continue
				}
				out.Repos[name] = chatUnreadRepo{Unread: unread, Since: since}
			}
			return out, nil
		},
	})
}

func registerChatGet(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[chatGetIn, chatGetOut]{
		Name:  "chat.get",
		Caps:  "C",
		Doc:   "One folded chat message with its direct replies; the permalink.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/chat/{seq}",
		Run: func(ctx context.Context, in chatGetIn) (chatGetOut, error) {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return chatGetOut{}, err
			}
			rest, err := d.Chat.Range(repo.Code, in.Seq, 0)
			if err != nil {
				return chatGetOut{}, err
			}
			folded := chat.Fold(rest)
			var target *chat.Message
			for i := range folded {
				if folded[i].Seq == in.Seq {
					target = &folded[i]
					break
				}
			}
			if target == nil {
				// Gone, and there are two ways to be gone: swept by the
				// window (below first), or removed from the timeline
				// (a hole inside it). Both are "this message is not
				// here any more", and neither is a typo — which is
				// what a bare 404 would tell a reader who followed a
				// reference out of a ticket.
				first, last, berr := d.Chat.Bounds(repo.Code)
				if berr == nil && first > 0 && in.Seq <= last {
					return chatGetOut{Expired: true}, nil
				}
				return chatGetOut{}, fmt.Errorf("no message %d: %w", in.Seq, ops.ErrNotFound)
			}
			// The context window, when asked for. Both halves join
			// the SAME render-and-quote pass as the target, so a
			// message in the window quotes its parent exactly as it
			// would in the live log — one code path, one appearance.
			var earlier, later []chat.Message
			var moreEarlier, moreLater uint64
			if in.Before > 0 {
				earlier, moreEarlier, err = d.Chat.History(repo.Code, in.Seq, in.Before)
				if err != nil {
					return chatGetOut{}, err
				}
			}
			if in.After > 0 {
				later, moreLater, err = d.Chat.Forward(repo.Code, in.Seq, in.After)
				if err != nil {
					return chatGetOut{}, err
				}
			}
			// Deleted and edited messages fold in the window too: a
			// permalink that showed the pre-edit text of a neighbour
			// would be a second, wrong record of the channel.
			earlier, later = chat.Fold(earlier), chat.Fold(later)

			// The whole folded run, quoted together, so the target
			// carries its reply count and each reply its quote; only
			// the target and its direct replies are then answered.
			all := d.chatRenderAll(ctx, repo, in.Repo, folded)
			chatQuote(all)
			out := chatGetOut{MoreEarlier: moreEarlier, MoreLater: moreLater}
			// Quoting runs over the window joined to the folded run, so
			// a neighbour's quote can be sourced from either.
			if len(earlier) > 0 || len(later) > 0 {
				ctxAll := d.chatRenderAll(ctx, repo, in.Repo, append(append([]chat.Message{}, earlier...), later...))
				chatQuote(append(append([]chatMsgOut{}, ctxAll...), all...))
				out.Earlier, out.Later = ctxAll[:len(earlier)], ctxAll[len(earlier):]
			}
			for _, m := range all {
				switch {
				case m.Seq == in.Seq:
					// The reply count comes from the SAME rows the
					// preview card reads, so the number on the page and
					// the number in the card are one number.
					if d.ChatRefs != nil {
						m.Replies = d.chatReplyCount(in.Repo, in.Seq)
					}
					out.Message = m
				case m.Ref == in.Seq && m.Kind == chat.KindMsg:
					out.Replies = append(out.Replies, m)
				}
			}
			return out, nil
		},
	})
}

func registerChatStream(r *ops.Registry, d Deps) error {
	return ops.Register(r, ops.Op[chatStreamIn, chatMsgOut]{
		Name: "chat.stream",
		Caps: "C",
		Doc: "Live chat messages as Server-Sent Events. The body is text/event-stream, not JSON: " +
			"each event's `id:` line is the message's stream sequence, and a client that reconnects " +
			"with `Last-Event-ID: <seq>` resumes after it. A resume older than the window replays " +
			"from the window's first message, preceded by an `event: gap` frame carrying " +
			"{\"from\":<asked>,\"first\":<available>} so a renderer re-renders rather than appends. " +
			"The sequence is the FORGE'S, not this channel's: every repository's chat shares one " +
			"stream, so the ids a reader of one channel receives are a subset and gaps between them " +
			"are ordinary rather than loss. Do not detect loss by looking for holes -- reconnecting " +
			"with Last-Event-ID is the only correct way to find out what you missed.",
		Kind:  ops.Stream,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/chat/stream",
		StreamRun: func(ctx context.Context, in chatStreamIn, s ops.Streamer) error {
			repo, err := d.repoFor(ctx, in.Repo)
			if err != nil {
				return err
			}
			msgs, cancel := d.Chat.Tail(repo.Code, in.From)
			defer cancel()
			heartbeat := time.NewTicker(20 * time.Second)
			defer heartbeat.Stop()
			for {
				select {
				case <-ctx.Done():
					return nil
				case <-heartbeat.C:
					s.Ping()
				case m, open := <-msgs:
					if !open {
						return nil
					}
					if m.Kind == chat.KindGap {
						// Not a message: the window no longer reaches
						// the resume point, so say so before the
						// replay rather than letting it look like a
						// clean continuation.
						if err := s.Event("gap", map[string]uint64{
							"from": m.Ref, "first": m.Seq,
						}); err != nil {
							return err
						}
						continue
					}
					// Raw, unfolded (RFC 0011 Drawbacks): a naive
					// consumer sees edit/delete tombstones as their own
					// events; a renderer applies them itself, the same
					// fold History already did server-side.
					if err := s.Emit(m.Seq, d.chatRenderOne(ctx, repo, in.Repo, m)); err != nil {
						return err
					}
				}
			}
		},
	})
}

// 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) {
	return renderBodyOn(ix, repo, "", mimetype, body)
}

// renderBodyOn is renderBody for a body that BELONGS to something: the
// target is what `attach:NAME` resolves against. Every other reference
// behaves identically, so a caller that has the target to hand loses
// nothing by passing it.
func renderBodyOn(ix *index.Index, repo, target, 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, attachTarget: target}, mimetype, body)
}

// landingMimetype is the encoding to render a landing record's posts
// with. A landing is quarry's own artifact and every one of them is
// typed into a box labelled Markdown, but zit writes the record with no
// N card unless the opener passes --mimetype, so the stored artifact
// claims fossil wiki and the reader gets literal asterisks and
// backticks. Here the origin of the body is known, so an absent card is
// read as what the author was writing rather than as fossil's default.
// The general path keeps that default, because a forum post genuinely
// can be fossil-born and a landing cannot.
func landingMimetype(mimetype string) string {
	if mimetype == "" {
		return authoredMimetype
	}
	return mimetype
}

// 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.
		//
		// SanitizeDoc rather than Sanitize: a document that ships its
		// diagrams as pre-rendered SVG keeps them, through the same
		// narrow policy a diagram quarry compiled itself gets. The
		// document policy still admits no <svg> of its own.
		return markup.SanitizeDoc(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

// archiveSlots bounds concurrent archive builds process-wide; the
// counters beside it feed the ops page's lanes.
var (
	archiveSlots   = make(chan struct{}, 2)
	archiveLaneMu  sync.Mutex
	archiveLaneHit = [2]int{} // admitted, refused
)

func archiveLane() zitcli.Lane {
	archiveLaneMu.Lock()
	defer archiveLaneMu.Unlock()
	return zitcli.Lane{Name: "archive builds", Bound: cap(archiveSlots), Running: len(archiveSlots),
		Admitted: archiveLaneHit[0], Refused: archiveLaneHit[1]}
}

func archiveCount(admitted bool) {
	archiveLaneMu.Lock()
	defer archiveLaneMu.Unlock()
	if admitted {
		archiveLaneHit[0]++
	} else {
		archiveLaneHit[1]++
	}
}

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

// hideCI says the caller may not see CI: a capability plane answered
// and the CI letter is not in the answer. Ops that carry CI facts
// beside other things — a check-in's verdict, a landing's checks, a
// timeline row's badge — blank them rather than refuse the whole
// answer, since the rest is theirs to read.
func hideCI(ctx context.Context) bool {
	return ops.HasCaps(ctx) && !ops.CapsOf(ctx).HasAny("8")
}

// attachCI is Index.AttachCI for callers the letter admits, and a
// no-op for the rest.
func attachCI(ctx context.Context, repo *Repo, entries []index.TimelineEntry) error {
	if hideCI(ctx) {
		return nil
	}
	return repo.Index.AttachCI(entries)
}