Skip to content
quarry Artifact [51eae34e5a]
Login

Artifact [51eae34e5a]

Artifact 51eae34e5abf400c0cac453aac52ad79c343b857f3f7903fd1bab961ed2901d9:


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

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

	"craftdesign.group/quarry/internal/chat"
	"craftdesign.group/quarry/internal/highlight"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/markup"
	"craftdesign.group/quarry/internal/ops"
	"craftdesign.group/quarry/internal/zitcli"
)

// Repo is one registered store's data-plane pair.
type Repo struct {
	Index *index.Index
	Zit   *zitcli.Client
	// 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)
	// 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
}

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

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

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

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

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

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

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

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

// synced runs the repo's ingest round after a successful write.
func (r *Repo) synced() error {
	if r.Sync == nil {
		return nil
	}
	_, err := r.Sync()
	return err
}

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

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

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

// Deps carries what op implementations close over. Repos may be empty
// (`quarry spec` runs storeless); ops answer unknown repos with
// not-found, and the spec stays complete either way.
type Deps struct {
	Version string
	Repos   map[string]*Repo
	Chat    ChatPlane
	Notify  NotifyPlane
	Hooks   WebhookPlane
	// CI is the run record; nil when no engine is configured, which is
	// how a forge without CI simply has no CI pages.
	CI CIRuns
}

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

// repo resolves one registered store; unknown names are not-found.
func (d Deps) repo(name string) (*Repo, error) {
	if r, ok := d.Repos[name]; ok {
		return r, nil
	}
	return nil, fmt.Errorf("no repository %q: %w", name, ops.ErrNotFound)
}

type versionIn struct{}

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

type repoListIn struct{}

type repoInfo struct {
	Name       string `json:"name"`
	Checkins   int    `json:"checkins"`
	TipDate    string `json:"tipDate"`
	TipComment string `json:"tipComment"`
	// Indexing is true while the first ingest is still folding, so a
	// zero count reads as "catching up", not "empty".
	Indexing bool `json:"indexing"`
}

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

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

type ticketNewIn struct {
	Repo    string `json:"repo" validate:"required,max=64"`
	Title   string `json:"title" validate:"required,max=200"`
	Status  string `json:"status" validate:"max=40"`
	Comment string `json:"comment" validate:"max=65536"`
}

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

type ticketSetIn struct {
	Repo       string `json:"repo" validate:"required,max=64"`
	ID         string `json:"id" validate:"required,min=4,max=40"`
	Title      string `json:"title" validate:"max=200"`
	Status     string `json:"status" validate:"max=40"`
	Resolution string `json:"resolution" validate:"max=40"`
	Comment    string `json:"comment" validate:"max=65536"`
}

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

type docsIn struct{}

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

type repoListOut struct {
	Repos []repoInfo `json:"repos"`
	Count int        `json:"count"`
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// treeEntry is one row of a directory listing. Dir entries are synthesised
// from the flat path set zit returns — a store has no directory objects,
// so a "directory" is a shared prefix and nothing more.
type treeEntry struct {
	Name  string `json:"name"`
	Path  string `json:"path"`
	Dir   bool   `json:"dir"`
	Blob  string `json:"blob,omitempty"`
	Files int    `json:"files,omitempty"` // for a directory: what it contains, recursively
}

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

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

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

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

type diffGetOut struct {
	From    string             `json:"from"`
	To      string             `json:"to"`
	Files   []zitcli.DiffEntry `json:"files"`
	Unified string             `json:"unified"`
	Note    string             `json:"note"` // set when the diff could not be fully computed
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// RegisterAll wires every op.
func RegisterAll(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[versionIn, versionOut]{
		Name:  "meta.version",
		Doc:   "Report the quarry build version.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/version",
		Run: func(_ context.Context, _ versionIn) (versionOut, error) {
			return versionOut{Version: d.Version}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[wikiSaveIn, wikiSaveOut]{
		Name:  "wiki.save",
		Doc:   "Record a wiki page edit (creates the page on first save).",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/wiki/{name}",
		Run: func(ctx context.Context, in wikiSaveIn) (wikiSaveOut, error) {
			repo, err := d.repo(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 = "text/x-markdown"
			}
			var hash string
			if err := repo.write(func() (err error) {
				hash, err = repo.Zit.WikiCommit(in.Name, author(ctx), mt, []byte(in.Body))
				return err
			}); err != nil {
				return wikiSaveOut{}, err
			}
			if err := repo.synced(); err != nil {
				return wikiSaveOut{}, err
			}
			d.fireNotify(NotifyEvent{
				Workflow: "quarry-activity", To: "repo:" + in.Repo,
				Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
					"summary": "edited wiki page " + in.Name,
					"link":    "/r/" + in.Repo + "/wiki/" + in.Name},
				TxnID: "activity:wiki:" + hash,
			})
			return wikiSaveOut{Hash: hash}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ticketNewIn, ticketNewOut]{
		Name:  "ticket.new",
		Doc:   "Open a new ticket.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/ticket",
		Run: func(ctx context.Context, in ticketNewIn) (ticketNewOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return ticketNewOut{}, err
			}
			status := in.Status
			if status == "" {
				status = "Open"
			}
			fields := map[string]string{"title": in.Title, "status": status}
			if in.Comment != "" {
				fields["comment"] = in.Comment
			}
			var id, hash string
			if err := repo.write(func() (err error) {
				id, hash, err = repo.Zit.TicketNew(author(ctx), fields)
				return err
			}); err != nil {
				return ticketNewOut{}, err
			}
			if err := repo.synced(); err != nil {
				return ticketNewOut{}, err
			}
			d.fireNotify(NotifyEvent{
				Workflow: "quarry-activity", To: "repo:" + in.Repo,
				Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
					"summary": "opened ticket " + in.Title,
					"link":    "/r/" + in.Repo + "/ticket/" + id},
				TxnID: "activity:ticket-new:" + hash,
			})
			return ticketNewOut{ID: id, Hash: hash}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ticketSetIn, ticketSetOut]{
		Name:  "ticket.set",
		Doc:   "Record a ticket change; empty fields are left untouched.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/ticket/{id}",
		Run: func(ctx context.Context, in ticketSetIn) (ticketSetOut, error) {
			repo, err := d.repo(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.
			t, ok, err := repo.Index.TicketByPrefix(in.ID)
			if err != nil {
				return ticketSetOut{}, err
			}
			if !ok {
				return ticketSetOut{}, fmt.Errorf("no unique ticket for %q: %w", in.ID, ops.ErrNotFound)
			}
			fields := map[string]string{}
			for k, v := range map[string]string{
				"title": in.Title, "status": in.Status,
				"resolution": in.Resolution, "comment": in.Comment,
			} {
				if v != "" {
					fields[k] = v
				}
			}
			if len(fields) == 0 {
				return ticketSetOut{}, fmt.Errorf("no fields to set: %w", ops.ErrInvalid)
			}
			var hash string
			if err := repo.write(func() (err error) {
				hash, err = repo.Zit.TicketSet(t.ID, author(ctx), fields)
				return err
			}); err != nil {
				return ticketSetOut{}, err
			}
			if err := repo.synced(); err != nil {
				return ticketSetOut{}, err
			}
			d.fireNotify(NotifyEvent{
				Workflow: "quarry-activity", To: "repo:" + in.Repo,
				Payload: map[string]any{"repo": in.Repo, "actor": author(ctx),
					"summary": "updated ticket " + t.ID[:min(10, len(t.ID))],
					"link":    "/r/" + in.Repo + "/ticket/" + t.ID[:min(10, len(t.ID))]},
				TxnID: "activity:ticket-set:" + hash,
			})
			return ticketSetOut{Hash: hash}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[docsIn, docsOut]{
		Name:  "meta.docs",
		Doc:   "The API reference, generated from this registry and rendered.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/docs",
		Run: func(_ 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(_ context.Context, _ repoListIn) (repoListOut, error) {
			names := make([]string, 0, len(d.Repos))
			for name := range d.Repos {
				names = append(names, name)
			}
			sort.Strings(names)
			out := repoListOut{Count: len(names)}
			for _, name := range names {
				info := repoInfo{Name: name}
				if r := d.Repos[name]; r != nil && r.Indexing != nil {
					info.Indexing = r.Indexing()
				}
				if ix := d.Repos[name].Index; ix != nil {
					info.Checkins, _ = ix.CheckinCount()
					if tip, _, err := ix.Timeline(1, "", "", "ci"); err == nil && len(tip) == 1 {
						info.TipDate, info.TipComment = tip[0].Date, tip[0].Comment
					}
				}
				out.Repos = append(out.Repos, info)
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[timelineIn, timelineOut]{
		Name:  "timeline.list",
		Doc:   "List a repository's events — check-ins, wiki edits, ticket changes — newest first, paged by `before`.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/timeline",
		Run: func(_ context.Context, in timelineIn) (timelineOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return timelineOut{}, err
			}
			entries, more, err := repo.Index.Timeline(in.Limit, in.Before, in.Branch, in.Y)
			if err != nil {
				return timelineOut{}, err
			}
			out := timelineOut{Entries: entries, Count: len(entries)}
			if more && len(entries) > 0 {
				last := entries[len(entries)-1]
				out.Next = last.Date + "~" + last.Hash
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[wikiListIn, wikiListOut]{
		Name:  "wiki.list",
		Doc:   "List a repository's wiki pages with their latest edit.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/wiki",
		Run: func(_ context.Context, in wikiListIn) (wikiListOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return wikiListOut{}, err
			}
			pages, err := repo.Index.WikiTitles()
			if err != nil {
				return wikiListOut{}, err
			}
			return wikiListOut{Pages: pages, Count: len(pages)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ticketListIn, ticketListOut]{
		Name:  "ticket.list",
		Doc:   "List a repository's tickets with their folded current state.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/ticket",
		Run: func(_ context.Context, in ticketListIn) (ticketListOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return ticketListOut{}, err
			}
			all, more, err := repo.Index.TicketSummaries(in.Limit, in.Before)
			if err != nil {
				return ticketListOut{}, err
			}
			out := ticketListOut{Count: len(all)}
			for _, t := range all {
				out.Tickets = append(out.Tickets, ticketSummary{
					ID: t.ID, Title: t.Title, Status: t.Status, Updated: t.Updated,
				})
			}
			if more && len(all) > 0 {
				last := all[len(all)-1]
				out.Next = last.Updated + "~" + last.ID
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ticketGetIn, ticketGetOut]{
		Name:  "ticket.get",
		Doc:   "Fetch one ticket: folded state plus change history.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/ticket/{id}",
		Run: func(_ context.Context, in ticketGetIn) (ticketGetOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return ticketGetOut{}, err
			}
			t, ok, err := repo.Index.TicketByPrefix(in.ID)
			if err != nil {
				return ticketGetOut{}, err
			}
			if !ok {
				return ticketGetOut{}, fmt.Errorf("no unique ticket for %q: %w", in.ID, ops.ErrNotFound)
			}
			// Attachments key off the FULL ticket id, not the prefix the
			// caller passed.
			att, err := repo.Index.AttachmentsFor(t.ID)
			if err != nil {
				return ticketGetOut{}, err
			}
			out := ticketGetOut{Ticket: t, Attachments: att}
			if t.Description != "" {
				html, err := renderBody(repo.Index, in.Repo, t.DescriptionMimetype, []byte(t.Description))
				if err != nil {
					return ticketGetOut{}, err
				}
				out.DescriptionHTML = html
			}
			for _, c := range t.Comments {
				html, err := renderBody(repo.Index, in.Repo, c.Mimetype, []byte(c.Body))
				if err != nil {
					return ticketGetOut{}, err
				}
				out.Comments = append(out.Comments, ticketComment{
					Date: c.Date, Login: c.Login, Username: c.Username, HTML: html,
				})
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[attachmentGetIn, attachmentGetOut]{
		Name:  "attachment.get",
		Doc:   "Fetch one attachment's content by its content-artifact hash.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/attachment/{hash}",
		Run: func(_ context.Context, in attachmentGetIn) (attachmentGetOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return attachmentGetOut{}, err
			}
			// The A card is the authority on what this blob is called and
			// whether it is still attached. Serving a blob the index does
			// not know as a live attachment would turn this route into a
			// general read-any-artifact endpoint.
			name, ok, err := repo.Index.AttachmentName(in.Hash)
			if err != nil {
				return attachmentGetOut{}, err
			}
			if !ok {
				return attachmentGetOut{}, fmt.Errorf("no attachment %q: %w", in.Hash, ops.ErrNotFound)
			}
			raw, err := repo.Zit.ArtifactRaw(in.Hash, maxAttachmentBytes)
			if err != nil {
				if errors.Is(err, zitcli.ErrNoEntry) {
					return attachmentGetOut{}, fmt.Errorf("attachment content unavailable: %w", ops.ErrNotFound)
				}
				return attachmentGetOut{}, err
			}
			return attachmentGetOut{Name: name, Size: len(raw),
				Content: base64.StdEncoding.EncodeToString(raw)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[ciListIn, ciListOut]{
		Name:  "ci.list",
		Doc:   "List a repository's CI runs, newest first.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/ci",
		Run: func(_ context.Context, in ciListIn) (ciListOut, error) {
			if _, err := d.repo(in.Repo); err != nil {
				return ciListOut{}, err
			}
			if d.CI == nil {
				return ciListOut{}, nil // no engine: an empty list, not an error
			}
			runs, err := d.CI.CIRuns(in.Repo, in.Limit)
			if err != nil {
				return ciListOut{}, err
			}
			return ciListOut{Runs: runs, Count: len(runs)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[branchListIn, branchListOut]{
		Name:  "branch.list",
		Doc:   "List branches with their tip check-in, most recently active first.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/branch",
		Run: func(_ context.Context, in branchListIn) (branchListOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return branchListOut{}, err
			}
			bs, err := repo.Index.Branches()
			if err != nil {
				return branchListOut{}, err
			}
			return branchListOut{Branches: bs, Count: len(bs)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[treeIn, treeOut]{
		Name:  "tree.list",
		Doc:   "List one directory of a check-in, with the directory's README rendered.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/tree/{version}/{path...}",
		Run: func(_ context.Context, in treeIn) (treeOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return treeOut{}, err
			}
			version := in.Version
			if version == "" {
				version = "trunk"
			}
			files, err := repo.Zit.Ls(version)
			if err != nil {
				return treeOut{}, fmt.Errorf("%w: %v", ops.ErrNotFound, err)
			}
			prefix := strings.Trim(in.Path, "/")
			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,
				})
			}
			for name, n := range dirs {
				entries = append(entries, treeEntry{
					Name: name, Path: prefix + name, Dir: true, Files: n,
				})
			}
			// Directories first, then names — the ordering every file
			// browser uses, because a tree read alphabetically with files
			// interleaved is hard to scan.
			sort.Slice(entries, func(i, j int) bool {
				if entries[i].Dir != entries[j].Dir {
					return entries[i].Dir
				}
				return entries[i].Name < entries[j].Name
			})
			out := treeOut{Version: version, Path: strings.Trim(in.Path, "/"), Entries: entries}

			// The README of THIS directory, rendered — what a forge shows
			// under the listing.
			for _, e := range entries {
				if e.Dir || !isReadme(e.Name) {
					continue
				}
				body, err := repo.Zit.Cat(e.Path, version)
				if err != nil {
					break // unreadable README is not a failed listing
				}
				html, err := renderBody(repo.Index, in.Repo, mimetypeOf(e.Name), body)
				if err != nil {
					break
				}
				out.Readme, out.ReadmeIn = html, e.Name
				break
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[searchIn, searchOut]{
		Name:  "search.query",
		Doc:   "Full-text search over a repository's check-ins, wiki, and tickets.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/search",
		Run: func(_ context.Context, in searchIn) (searchOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return searchOut{}, err
			}
			results, err := repo.Index.Search(in.Q, in.Limit)
			if err != nil {
				return searchOut{}, err
			}
			return searchOut{Results: results, Count: len(results)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[diffGetIn, diffGetOut]{
		Name:  "checkin.diff",
		Doc:   "Diff one check-in against its primary parent.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/checkin/{hash}/diff",
		Run: func(_ context.Context, in diffGetIn) (diffGetOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return diffGetOut{}, err
			}
			e, parents, ok, err := repo.Index.CheckinByPrefix(in.Hash)
			if err != nil {
				return diffGetOut{}, err
			}
			if !ok {
				return diffGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound)
			}
			out := diffGetOut{To: e.Hash}
			if len(parents) == 0 {
				// A root check-in has nothing to diff against; its
				// whole file set is the change.
				files, err := repo.Zit.Ls(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(out.From, e.Hash)
			if err != nil {
				return diffGetOut{}, err
			}
			out.Unified, out.Files = unified, brief
			// zit exits 0 with empty output when changed content is
			// unavailable (shunned) — flag it rather than serving what
			// looks like a legitimate no-op diff.
			if out.Unified == "" && len(out.Files) > 0 {
				out.Note = "diff content unavailable — the changed file content may have been shunned from the store"
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[fileGetIn, fileGetOut]{
		Name:  "file.get",
		Doc:   "Fetch one file's content at a check-in.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/file/{hash}/{path...}",
		Run: func(_ context.Context, in fileGetIn) (fileGetOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return fileGetOut{}, err
			}
			// A hash prefix resolves through the index; anything else —
			// "trunk", "tip", a tag — is a name only zit resolves, and
			// zit is the authority on names (0001 §2). Fossil's /doc
			// links use exactly those symbolic versions.
			version := in.Hash
			if e, _, ok, err := repo.Index.CheckinByPrefix(in.Hash); err != nil {
				return fileGetOut{}, err
			} else if ok {
				version = e.Hash
			}
			raw, err := repo.Zit.Cat(in.Path, version)
			if errors.Is(err, zitcli.ErrNoEntry) {
				// Clean public message; the zit detail stays behind.
				return fileGetOut{}, fmt.Errorf("no file %q at this check-in: %w", in.Path, ops.ErrNotFound)
			}
			if err != nil {
				return fileGetOut{}, err
			}
			out := fileGetOut{Hash: version, Path: in.Path, Size: len(raw)}
			// Binary content stays out of the JSON surface; the page
			// says "binary" and the artifact read verb serves bytes.
			if utf8.Valid(raw) && !bytes.ContainsRune(raw, 0) {
				out.Content = string(raw)
				// Best-effort highlighting; sanitized like all rendered
				// HTML even though quarry generated it.
				if hl, ok := highlight.Code(raw, "", in.Path); ok {
					out.HTML = markup.Sanitize(hl)
				}
			} else {
				out.Binary = true
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[checkinGetIn, checkinGetOut]{
		Name:  "checkin.get",
		Doc:   "Describe one check-in: metadata, parents, and its file set.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/checkin/{hash}",
		Run: func(_ context.Context, in checkinGetIn) (checkinGetOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return checkinGetOut{}, err
			}
			e, parents, ok, err := repo.Index.CheckinByPrefix(in.Hash)
			if err != nil {
				return checkinGetOut{}, err
			}
			if !ok {
				return checkinGetOut{}, fmt.Errorf("no unique check-in for %q: %w", in.Hash, ops.ErrNotFound)
			}
			entries := []index.TimelineEntry{e}
			if err := repo.Index.AttachCI(entries); err != nil {
				return checkinGetOut{}, err
			}
			out := checkinGetOut{
				Hash: e.Hash, CI: entries[0].CI, Branch: e.Branch, Date: e.Date,
				User: e.User, Comment: e.Comment, Parents: parents,
			}
			// File set is an on-demand zit read (0001 §2), not index
			// state — the index answers "which check-in", the store
			// answers "what does it hold".
			if repo.Zit != nil {
				files, err := repo.Zit.Ls(e.Hash)
				if err != nil {
					return checkinGetOut{}, err
				}
				out.Files = files
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[wikiHistoryIn, wikiHistoryOut]{
		Name:  "wiki.history",
		Doc:   "List every recorded edit of one wiki page, newest first.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/wiki/{name}/history",
		Run: func(_ context.Context, in wikiHistoryIn) (wikiHistoryOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return wikiHistoryOut{}, err
			}
			edits, err := repo.Index.WikiHistory(in.Name)
			if err != nil {
				return wikiHistoryOut{}, err
			}
			if len(edits) == 0 {
				return wikiHistoryOut{}, fmt.Errorf("no wiki page %q: %w", in.Name, ops.ErrNotFound)
			}
			return wikiHistoryOut{Title: in.Name, Edits: edits, Count: len(edits)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[wikiGetIn, wikiGetOut]{
		Name:  "wiki.get",
		Doc:   "Fetch one wiki page, rendered — latest, or the revision named by rev.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/wiki/{name}",
		Run: func(_ context.Context, in wikiGetIn) (wikiGetOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return wikiGetOut{}, err
			}
			page, ok, err := repo.Index.WikiRevision(in.Name, in.Rev)
			if err != nil {
				return wikiGetOut{}, err
			}
			if !ok {
				return wikiGetOut{}, fmt.Errorf("no wiki page %q rev %q: %w", in.Name, in.Rev, ops.ErrNotFound)
			}
			html, err := renderBody(repo.Index, in.Repo, page.Mimetype, page.Body)
			if err != nil {
				return wikiGetOut{}, err
			}
			return wikiGetOut{
				Hash: page.Hash, Title: page.Title, Date: page.Date, User: page.User,
				Mimetype: page.Mimetype, Source: string(page.Body), HTML: html,
			}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[technoteListIn, technoteListOut]{
		Name:  "technote.list",
		Doc:   "List a repository's technotes at their latest edit, newest first.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/technote",
		Run: func(_ context.Context, in technoteListIn) (technoteListOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return technoteListOut{}, err
			}
			notes, err := repo.Index.Technotes()
			if err != nil {
				return technoteListOut{}, err
			}
			return technoteListOut{Notes: notes, Count: len(notes)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[technoteGetIn, technoteGetOut]{
		Name:  "technote.get",
		Doc:   "Fetch one technote, rendered, by event-id prefix.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/technote/{event}",
		Run: func(_ context.Context, in technoteGetIn) (technoteGetOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return technoteGetOut{}, err
			}
			n, ok, err := repo.Index.TechnoteByEvent(in.Event)
			if err != nil {
				return technoteGetOut{}, err
			}
			if !ok {
				return technoteGetOut{}, fmt.Errorf("no technote %q: %w", in.Event, ops.ErrNotFound)
			}
			html, err := renderBody(repo.Index, in.Repo, n.Mimetype, []byte(n.Body))
			if err != nil {
				return technoteGetOut{}, err
			}
			return technoteGetOut{Event: n.Event, Date: n.Date, User: n.User,
				Comment: n.Comment, Body: n.Body, HTML: html}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[forumListIn, forumListOut]{
		Name:  "forum.list",
		Doc:   "List a repository's forum threads, most-recently-active first.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/forum",
		Run: func(_ context.Context, in forumListIn) (forumListOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return forumListOut{}, err
			}
			threads, err := repo.Index.ForumThreads()
			if err != nil {
				return forumListOut{}, err
			}
			return forumListOut{Threads: threads, Count: len(threads)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[forumGetIn, forumGetOut]{
		Name:  "forum.get",
		Doc:   "Fetch one forum thread: every post, rendered, in reading order.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/r/{repo}/forum/{root}",
		Run: func(_ context.Context, in forumGetIn) (forumGetOut, error) {
			repo, err := d.repo(in.Repo)
			if err != nil {
				return forumGetOut{}, err
			}
			title, posts, ok, err := repo.Index.ForumThreadPosts(in.Root)
			if err != nil {
				return forumGetOut{}, err
			}
			if !ok {
				return forumGetOut{}, fmt.Errorf("no forum thread %q: %w", in.Root, ops.ErrNotFound)
			}
			out := forumGetOut{Root: in.Root, Title: title}
			for _, p := range posts {
				// Dispatch on the post's own N card (absent = Fossil wiki,
				// matching fossil's forum_render).
				html, err := renderBody(repo.Index, in.Repo, p.Mimetype, []byte(p.Body))
				if err != nil {
					return forumGetOut{}, err
				}
				out.Posts = append(out.Posts, forumRendered{
					Hash: p.Hash, Parent: p.Parent, Date: p.Date, User: p.User, HTML: html,
				})
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := registerAuthoring(r, d); err != nil {
		return err
	}
	if d.Chat != nil {
		return registerChat(r, d)
	}
	return nil
}

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

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

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

// replyMimetype applies the same N-card default the other authoring ops
// use: an omitted card is text/x-fossil-wiki by fossil's rule, which is
// not what quarry's markdown forms produce.
func replyMimetype(in string) string {
	if in == "" {
		return "text/x-markdown"
	}
	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)
	for _, stem := range []string{"readme.md", "readme.markdown", "readme.txt", "readme"} {
		if lower == stem {
			return true
		}
	}
	return false
}

// mimetypeOf maps a filename to the markup dispatch renderBody expects.
// A bare README with no extension is markdown by convention here, which
// is what a forge assumes and what authors write.
func mimetypeOf(name string) string {
	if strings.HasSuffix(strings.ToLower(name), ".txt") {
		return "text/plain"
	}
	return "text/x-markdown"
}