Skip to content
quarry Artifact [9d3d0f52c4]
Login

Artifact [9d3d0f52c4]

Artifact 9d3d0f52c4bd2014b484a3ea9654c7dd25010fee1452b29395f794e8a0c70bf0:


package index

import (
	"database/sql"
	"strings"
)

// A landing (zit#127) is a branch proposed to land on a target, carried
// entirely by artifacts fossil already understands: the record is a forum
// thread whose root body opens with a marker line, every state change is a
// reply in that thread, and the outcome is a raw tag on the check-in that
// landed. zit mints no card type for it, so there is nothing here to fold
// from a new artifact kind — a landing is a READING of forum_post rows.
//
// That is why this table is derived and recomputed rather than written once
// on arrival. Folding is ORDER-INDEPENDENT (0001 §5): a reply can be indexed
// before the root it belongs to, a root can arrive after its own acceptance,
// and either order must converge on the same row. So any forum post in a
// thread recomputes that thread's landing from the forum_post rows present,
// instead of trying to patch a row incrementally from the artifact in hand.
//
// The fold rule matches zit's exactly — latest transition wins, ordered by
// (date, hash) — because two readers of the same store disagreeing about a
// landing's state is the failure this whole feature exists to avoid.

// landingMarker opens the body of a thread root that is a landing record.
// Versioned at the source; a body announcing a version this code does not
// know is NOT a landing here, which is a refusal to guess rather than an
// oversight. Reading it as a landing anyway would mean rendering fields we
// only think we recognise.
const landingMarker = "zit-landing: 1"

// IsLandingBody reports whether a forum post opens a landing. Exported
// so the ingest fold can name the event correctly: a landing IS a forum
// thread, and calling its opening post forum.posted would be true but
// useless — nothing wanting landings could select them.
func IsLandingBody(body string) bool {
	return strings.Contains(body, landingMarker)
}

// IsLandingStateBody reports whether a post carries a landing verdict.
func IsLandingStateBody(body string) bool {
	return strings.Contains(body, landingStateMarker)
}

// landingStateMarker opens the body of a reply carrying a state transition.
const landingStateMarker = "zit-landing-state:"

// landingTargetMarker opens the body of a reply RETARGETING the record
// (zit#144). A landing's target is not fixed at open: when the branch
// below a stack lands, zit retargets whatever was stacked on it onto
// the branch that just landed, and records that as a reply — the same
// latest-wins shape a state transition uses. Reading the target from
// the opening post alone leaves the record pointing at a branch that
// has already landed, and every question asked of it after that — most
// of all "did this merge?" — is asked about the wrong branch.
const landingTargetMarker = "zit-landing-target:"

// parseLandingTarget reads a retarget reply. Unlike a state, a branch
// name is not a closed set, so anything non-empty on the marker line is
// the new target; an empty one is a comment with an odd first line.
func parseLandingTarget(body string) (string, bool) {
	if !strings.HasPrefix(body, landingTargetMarker) {
		return "", false
	}
	rest := body[len(landingTargetMarker):]
	if i := strings.IndexByte(rest, '\n'); i >= 0 {
		rest = rest[:i]
	}
	if t := strings.TrimSpace(rest); t != "" {
		return t, true
	}
	return "", false
}

// landingStates is the closed set. A reply naming anything else is a comment
// with an odd first line, not a transition — an unknown state must never
// settle a record.
var landingStates = map[string]bool{"open": true, "accepted": true, "rejected": true}

// parseLandingHeader reads branch and target out of a thread root's body.
// ok is false for every ordinary forum post, which is the common case.
func parseLandingHeader(body string) (branch, target string, ok bool) {
	if !strings.HasPrefix(body, landingMarker) {
		return "", "", false
	}
	lines := strings.Split(body, "\n")
	for _, line := range lines[1:] {
		if line == "" || line == "\r" {
			break // the blank line ends the header
		}
		switch {
		case strings.HasPrefix(line, "branch: "):
			branch = strings.TrimSpace(line[len("branch: "):])
		case strings.HasPrefix(line, "target: "):
			target = strings.TrimSpace(line[len("target: "):])
		default:
			// An unrecognised header line under a version we DO claim to
			// know is a dialect mismatch. Refuse the record rather than
			// index a partial reading of it.
			return "", "", false
		}
	}
	if branch == "" || target == "" {
		return "", "", false
	}
	return branch, target, true
}

// parseLandingState reads the state a reply carries, or ok=false when the
// reply is an ordinary comment.
func parseLandingState(body string) (string, bool) {
	if !strings.HasPrefix(body, landingStateMarker) {
		return "", false
	}
	rest := body[len(landingStateMarker):]
	if i := strings.IndexByte(rest, '\n'); i >= 0 {
		rest = rest[:i]
	}
	s := strings.TrimSpace(rest)
	if !landingStates[s] {
		return "", false
	}
	return s, true
}

// LandingProse is a landing root's body with its header block removed — the
// message the author actually wrote.
//
// The header is ENCODING, and zit's landing module is explicit that the
// interface hides it ("if a thread hash leaks into the interface ... this has
// become a pass-through wrapper"). A page rendering the raw root body shows
// `zit-landing: 1` to a human, which is the encoding leaking through the
// interface built to hide it. Stripping it belongs here, beside the parser
// that defines where the header ends, rather than in each reader.
func LandingProse(body string) string {
	if !strings.HasPrefix(body, landingMarker) {
		return body
	}
	// The blank line ends the header, exactly as parseLandingHeader reads it.
	if i := strings.Index(body, "\n\n"); i >= 0 {
		return strings.TrimLeft(body[i+2:], "\n")
	}
	return ""
}

// notALanding is the clause that keeps landing records off the forum.
//
// A landing IS a forum thread — that is the compatibility story, and it is
// why a stock fossil clone can read one. But it is not forum CONTENT: it is a
// decision record with its own surface, its own controls and its own listing,
// and a reader scanning discussions has no use for a thread whose first line
// is a marker.
//
// The clause lives here, once, because a listing and the count above it
// disagreeing about what a forum holds is the confirmation-leak shape in
// miniature: the number would promise a thread the list refuses to show.
func notALanding(col string) string {
	return col + ` NOT IN (SELECT id FROM landing)`
}

// LandingByPrefix resolves a landing from an id prefix, the way every other
// hash in this forge resolves: a prefix matching two records matches neither,
// because answering arbitrarily is worse than refusing.
func (ix *Index) LandingByPrefix(prefix string) (LandingSummary, bool, error) {
	rows, err := ix.db.Query(`SELECT id, branch, target, title, author, opened, updated, state, settled_by, superseded_by, claimed_by
		FROM landing WHERE id >= ? AND id < ? LIMIT 2`, prefix, prefix+"g")
	if err != nil {
		return LandingSummary{}, false, err
	}
	defer rows.Close()
	var out LandingSummary
	n := 0
	for rows.Next() {
		n++
		if n > 1 {
			return LandingSummary{}, false, rows.Err()
		}
		if err := rows.Scan(&out.ID, &out.Branch, &out.Target, &out.Title, &out.Author,
			&out.Opened, &out.Updated, &out.State, &out.SettledBy, &out.SupersededBy, &out.ClaimedBy); err != nil {
			return LandingSummary{}, false, err
		}
	}
	return out, n == 1, rows.Err()
}

// LandingTransition reports whether a post body is a state transition rather
// than a remark, and which state it carries.
//
// Exported because the API has to tell the two apart to render them
// differently: a transition is an EVENT in the record ("dan accepted this"),
// the way every forge renders a state change, while a remark is a comment.
// Reading the marker in a second place would be a second dialect, so the
// classification stays with the fold that defines it.
func LandingTransition(body string) (state string, ok bool) {
	return parseLandingState(body)
}

// LandingRetarget reports the new target a reply announces, so a reader
// sees the retarget as the EVENT it is rather than as a remark whose
// first line happens to be machine text.
func LandingRetarget(body string) (target string, ok bool) {
	return parseLandingTarget(body)
}

// landingUpdatedMarker opens the body of a reply recording that the
// target was brought INTO the branch on the store (zit#152, `landing
// update`): the merge check-in's hash follows it. An event, not a
// remark — rendering it as prose puts the marker in the discussion.
const landingUpdatedMarker = "zit-landing-updated:"

// LandingUpdate reads an update reply: the hash of the merge it wrote.
func LandingUpdate(body string) (merge string, ok bool) {
	if !strings.HasPrefix(body, landingUpdatedMarker) {
		return "", false
	}
	rest := body[len(landingUpdatedMarker):]
	if i := strings.IndexByte(rest, '\n'); i >= 0 {
		rest = rest[:i]
	}
	if h := strings.TrimSpace(rest); h != "" {
		return h, true
	}
	return "", false
}

// foldLanding recomputes one thread's landing row from the forum_post rows
// currently present, and is called for every forum post folded in that
// thread. Cheap: one thread is a handful of rows, and the alternative —
// patching incrementally — cannot be made order-independent.
//
// A thread that is not (or is no longer) a landing has its row DELETED, so a
// record can never outlive the post it was read from.
func foldLanding(tx *sql.Tx, thread string) error {
	var body, title, user, opened string
	err := tx.QueryRow(`SELECT body, title, user, date FROM forum_post WHERE hash = ?`,
		thread).Scan(&body, &title, &user, &opened)
	if err == sql.ErrNoRows {
		// The root has not been folded yet. Not an error and not a gap:
		// whichever post arrives last recomputes the whole thread, so the
		// row appears as soon as the root does.
		return nil
	}
	if err != nil {
		return err
	}

	branch, target, ok := parseLandingHeader(body)
	if !ok {
		if _, err := tx.Exec(`DELETE FROM landing WHERE id = ?`, thread); err != nil {
			return err
		}
		// Whatever this thread is, it is forum content again. Only
		// reachable by a refold that saw the root differently, but the
		// re-filing below is not reversible any other way and a one-way
		// classification is the kind that leaves a store wrong for ever.
		_, err := tx.Exec(`UPDATE event SET kind = 'f', ref = ?,
			comment = (SELECT 'Forum: ' || CASE WHEN p.title != '' THEN p.title
				ELSE substr(p.body, 1, 60) END FROM forum_post p WHERE p.hash = event.hash)
			WHERE hash IN (SELECT hash FROM forum_post WHERE thread = ?)`, thread, thread)
		return err
	}

	// Ascending, so the LAST transition seen wins — zit's own fold rule,
	// deliberately mirrored rather than re-derived.
	rows, err := tx.Query(`SELECT hash, body, date FROM forum_post
		WHERE thread = ? AND hash <> ? ORDER BY date, hash`, thread, thread)
	if err != nil {
		return err
	}
	defer rows.Close()

	// verdicts holds each reply that ANNOUNCES a state, by its own
	// state rather than the thread's final one: a timeline row says
	// what happened at that moment, not how the story ended.
	verdicts := map[string]string{}
	// replyState is what the THREAD says, read the way zit's own fold
	// reads it: the last transition wins.
	replyState, replyBy := "open", ""
	updated := opened
	for rows.Next() {
		var hash, replyBody, date string
		if err := rows.Scan(&hash, &replyBody, &date); err != nil {
			return err
		}
		if date > updated {
			updated = date
		}
		if s, ok := parseLandingState(replyBody); ok {
			replyState, replyBy = s, hash
			verdicts[hash] = s
		}
		// Latest wins, exactly as the state above does: the rows are
		// already ordered ascending for that reason.
		if t, ok := parseLandingTarget(replyBody); ok {
			target = t
		}
		if _, ok := LandingUpdate(replyBody); ok {
			verdicts[hash] = "updated"
		}
	}
	if err := rows.Err(); err != nil {
		return err
	}

	// RFC 0007: the merge is the fact and the reply is the claim. A
	// landing is accepted when the DAG holds the check-in that merged
	// it — whoever posted what. A reply asserting acceptance with no
	// merge behind it leaves the record open and is recorded as a
	// claim, so the page can say one was made. Unconditional on
	// purpose: a fact about the DAG is the same fact under any policy,
	// which is what lets Disposability hold by construction.
	//
	// Rejection has no DAG fact to read, so there the reply stays the
	// record — as it was.
	merge, err := mergeOf(tx, branch, target, opened)
	if err != nil {
		return err
	}
	state, settledBy, claimedBy := "open", "", ""
	// A reply says accepted and no merge check-in exists. Before calling
	// that a claim, one more DAG fact: the branch may already be
	// CONTAINED in the target — zit settles such a landing as accepted
	// with nothing to merge ("merges up-to-date"), and its work is on
	// the target. Tip ancestry is the wrong general test (a branch
	// continued past its merge un-settles) but the right validation of
	// a claim, and it runs only then.
	contained := false
	if merge == "" && replyState == "accepted" {
		var err error
		if contained, err = containedIn(tx, branch, target); err != nil {
			return err
		}
	}
	switch {
	case merge != "" || contained:
		state = "accepted"
		if replyState == "accepted" {
			settledBy = replyBy
		}
	case replyState == "rejected":
		state, settledBy = "rejected", replyBy
	case replyState == "accepted":
		claimedBy = replyBy
		verdicts[replyBy] = "claimed"
	}

	if _, err := tx.Exec(`INSERT OR REPLACE INTO landing
		(id, branch, target, title, author, opened, updated, state, settled_by, claimed_by)
		VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		thread, branch, target, title, user, opened, updated, state, settledBy, claimedBy); err != nil {
		return err
	}
	if err := refileLandingEvents(tx, thread, title, verdicts); err != nil {
		return err
	}
	return supersedeLandings(tx, branch)
}

// mergeOf finds the check-in that landed a proposal: the earliest on
// the target, opened or later, whose non-primary parent is on the
// proposed branch. Structural — the P card zit wrote when it merged —
// not tip ancestry, which un-settles a branch continued past its
// merge. The same shape MergesForLandings reads, inside the fold's
// transaction.
//
// Empty when the branch column is not yet computed for the tip, which
// is the case for check-ins folded in the current batch; the refresh
// after recomputeBranches closes that gap.
func mergeOf(tx *sql.Tx, branch, target, opened string) (string, error) {
	var hash string
	err := tx.QueryRow(`SELECT c.hash FROM checkin c
		JOIN checkin_parent p ON p.child = c.hash AND p.ord > 0
		JOIN checkin tip ON tip.hash = p.parent
		WHERE tip.branch = ? AND c.branch = ? AND c.date >= ?
		ORDER BY c.date, c.hash LIMIT 1`, branch, target, opened).Scan(&hash)
	if err == sql.ErrNoRows {
		return "", nil
	}
	return hash, err
}

// containedIn reports whether branch's tip is an ancestor of target's
// tip — the DAG fact behind "already on the target". Bounded, because
// a walk from trunk's tip on a large store visits every check-in ever
// made; the bound is far above any real branch depth and turns a
// pathological store into a "no" rather than a hang.
func containedIn(tx *sql.Tx, branch, target string) (bool, error) {
	tip := func(b string) (string, error) {
		var h string
		err := tx.QueryRow(`SELECT c.hash FROM checkin c
			WHERE c.branch = ? AND NOT EXISTS (
				SELECT 1 FROM checkin_parent p JOIN checkin k ON k.hash = p.child
				WHERE p.parent = c.hash AND k.branch = ?)
			ORDER BY c.date DESC, c.hash DESC LIMIT 1`, b, b).Scan(&h)
		if err == sql.ErrNoRows {
			return "", nil
		}
		return h, err
	}
	bt, err := tip(branch)
	if err != nil || bt == "" {
		return false, err
	}
	tt, err := tip(target)
	if err != nil || tt == "" {
		return false, err
	}
	var one int
	err = tx.QueryRow(`WITH RECURSIVE anc(hash, d) AS (
			SELECT ?, 0
			UNION
			SELECT p.parent, anc.d + 1 FROM checkin_parent p JOIN anc ON p.child = anc.hash
			WHERE anc.d < 20000)
		SELECT 1 FROM anc WHERE hash = ? LIMIT 1`, tt, bt).Scan(&one)
	if err == sql.ErrNoRows {
		return false, nil
	}
	return err == nil, err
}

// refreshLandingStates re-derives every unsettled landing after the
// branch columns are recomputed. A merge check-in folded in the same
// batch as the reply that announced it — or pushed from a clone with
// no reply at all — is only findable once its tip's branch is known,
// and this is the first moment it is. Rejected records are left alone:
// they have no DAG fact to converge on.
//
// Every fold of a landing thread recomputes it from scratch, so this is
// the same function the posts run, not a second rule beside it.
func refreshLandingStates(tx *sql.Tx) error {
	rows, err := tx.Query(`SELECT id FROM landing WHERE state <> 'rejected'`)
	if err != nil {
		return err
	}
	var ids []string
	for rows.Next() {
		var id string
		if err := rows.Scan(&id); err != nil {
			rows.Close()
			return err
		}
		ids = append(ids, id)
	}
	rows.Close()
	if err := rows.Err(); err != nil {
		return err
	}
	for _, id := range ids {
		if err := foldLanding(tx, id); err != nil {
			return err
		}
	}
	return nil
}

// supersedeLandings decides which of several open landings on one
// branch is the current one.
//
// zit#127 left this as "probably yes, refused by name on the second"
// and neither side enforces it. Refusing at open is the courtesy and it
// belongs in the writer; it cannot be the whole answer, because two
// people can open a landing for one branch in separate clones and both
// are ordinary forum threads that are valid artifacts when they meet.
// Sync makes prevention unenforceable, so the readers have to agree.
//
// The newest root wins, by (opened, id). Newest rather than oldest
// because a second landing is the author saying something about the
// branch NOW, and because it is the rule the state fold already uses —
// two folds in one file disagreeing about which artifact supersedes
// which is a worse thing to own than either rule.
//
// SETTLED landings never take part. An accepted landing is history and
// its check-in is on the target; a later proposal for the same branch
// is a new proposal, not a correction of a finished one.
//
// The losers keep their rows and their discussion — nothing is deleted,
// because a record somebody argued in is not noise. They are marked, so
// the queue can show one open landing per branch and the page can say
// where the conversation moved.
func supersedeLandings(tx *sql.Tx, branch string) error {
	if branch == "" {
		return nil
	}
	rows, err := tx.Query(`SELECT id FROM landing
		WHERE branch = ? AND state = 'open' ORDER BY opened DESC, id DESC`, branch)
	if err != nil {
		return err
	}
	var open []string
	for rows.Next() {
		var id string
		if err := rows.Scan(&id); err != nil {
			rows.Close()
			return err
		}
		open = append(open, id)
	}
	rows.Close()
	if err := rows.Err(); err != nil {
		return err
	}
	if len(open) == 0 {
		return nil
	}
	// Recomputed from scratch every fold, never accumulated: a landing
	// that was superseded and then settled elsewhere must be able to
	// become current again when the winner is settled or withdrawn.
	winner := open[0]
	for _, id := range open {
		to := winner
		if id == winner {
			to = ""
		}
		if _, err := tx.Exec(`UPDATE landing SET superseded_by = ? WHERE id = ?`,
			to, id); err != nil {
			return err
		}
	}
	// A settled landing is never superseded by an open one.
	_, err = tx.Exec(`UPDATE landing SET superseded_by = ''
		WHERE branch = ? AND state <> 'open' AND superseded_by <> ''`, branch)
	return err
}

// refileLandingEvents moves a landing's posts off the forum kind.
//
// A landing IS a forum thread, so the forum trigger files every post in
// one as kind 'f' with a "Forum: " prefix — which produced timeline
// rows reading "Forum: zit-landing-state: accepted Accepted. The forge
// now tells y", a prefix over a marker over a truncated body. quarry
// had already decided these are not forum content: notALanding keeps
// them out of the forum listing and its count. The timeline was the
// surface that missed the memo, and surfaces disagreeing about what a
// forum holds is the same shape as a count promising a thread the list
// refuses to show.
//
// This runs HERE, not in the trigger, because the trigger fires while
// the forum post is being inserted and the landing row does not exist
// yet — the root post is what the landing row is derived FROM. Any post
// in a thread recomputes the record, so classification converges no
// matter which order a clone delivers them in.
//
// Verdicts are 'l' and plain comments are 'lc'. A verdict changes the
// target branch and belongs in a mixed timeline; a comment is
// conversation, and three landings produced six rows of it.
func refileLandingEvents(tx *sql.Tx, thread, title string, verdicts map[string]string) error {
	hashes, err := tx.Query(`SELECT hash FROM forum_post WHERE thread = ?`, thread)
	if err != nil {
		return err
	}
	defer hashes.Close()
	var posts []string
	for hashes.Next() {
		var h string
		if err := hashes.Scan(&h); err != nil {
			return err
		}
		posts = append(posts, h)
	}
	if err := hashes.Err(); err != nil {
		return err
	}
	for _, h := range posts {
		kind, label := "lc", "Comment on landing: "+title
		switch {
		case h == thread:
			kind, label = "l", "Landing: "+title
		case verdicts[h] == "claimed":
			// Asserted acceptance with nothing merged. Conversation,
			// not a verdict: it changed no branch.
			kind, label = "lc", "Acceptance claimed on landing: "+title
		case verdicts[h] == "updated":
			// The target brought into the branch. Housekeeping on the
			// proposal, not a decision about it.
			kind, label = "lc", "Landing updated from its target: "+title
		case verdicts[h] != "":
			kind, label = "l", "Landing "+verdicts[h]+": "+title
		}
		if _, err := tx.Exec(`UPDATE event SET kind = ?, ref = ?, comment = ?
			WHERE hash = ?`, kind, thread, label, h); err != nil {
			return err
		}
	}
	return nil
}

// backfillLandings populates the table for indexes written before it
// existed, for the reason backfillTickets gives: the fold only writes it on
// newly arriving posts, so an upgraded index would show an EMPTY landing list
// until someone reindexed a store whose forum_post rows are already complete.
//
// Once, at open, and only when there is nothing to lose: a non-empty table is
// left alone.
func (ix *Index) backfillLandings() error {
	var landings, posts int
	if err := ix.db.QueryRow(`SELECT count(*) FROM landing`).Scan(&landings); err != nil {
		return err
	}
	if err := ix.db.QueryRow(`SELECT count(*) FROM forum_post`).Scan(&posts); err != nil {
		return err
	}
	if landings > 0 || posts == 0 {
		return nil
	}
	rows, err := ix.db.Query(`SELECT hash FROM forum_post WHERE hash = thread AND body LIKE ?`,
		landingMarker+"%")
	if err != nil {
		return err
	}
	var roots []string
	for rows.Next() {
		var h string
		if err := rows.Scan(&h); err != nil {
			rows.Close()
			return err
		}
		roots = append(roots, h)
	}
	rows.Close()
	if err := rows.Err(); err != nil {
		return err
	}
	if len(roots) == 0 {
		return nil
	}
	// The LIKE above is a cheap prefilter; parseLandingHeader inside
	// foldLanding is the real gate, so a body that merely starts with the
	// marker but carries an unknown header is still refused.
	tx, err := ix.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	for _, root := range roots {
		if err := foldLanding(tx, root); err != nil {
			return err
		}
	}
	return tx.Commit()
}

// VerdictsForLandings reports each landing's CI verdict — "ci-pass",
// "ci-fail", or absent when no run has recorded one.
//
// The ticket asked which shape a landing verdict should take, forum
// reply or tag, before wiring the display. Neither, because the shape
// already exists: a verdict is a tag artifact on a CHECK-IN, quarry
// already folds and reads them, and a landing proposes a branch whose
// tip is a check-in. So a landing's verdict is its branch tip's
// verdict, and there is nothing new to mint.
//
// That is also the better answer on its own terms. The verdict stays
// attached to the thing that was actually tested rather than to a
// discussion record; it keeps the zit#127 invariant with no new
// vocabulary, because a stock fossil clone already understands the tag;
// and a landing retargeted or reopened inherits the right answer
// without anyone copying it across.
func (ix *Index) VerdictsForLandings(branches []string) (map[string]string, error) {
	out := map[string]string{}
	for _, b := range branches {
		if b == "" || out[b] != "" {
			continue
		}
		tip, err := ix.branchTip(b)
		if err != nil {
			return nil, err
		}
		if tip == "" {
			continue
		}
		st, err := ix.tagState([]string{tip})
		if err != nil {
			return nil, err
		}
		if v := st[tip].ci; v != "" {
			out[b] = v
		}
	}
	return out, nil
}

// OpenLandingOn reports the current open landing for a branch, if
// there is one. The courtesy check a writer makes before opening a
// second: see supersedeLandings for why it cannot be the whole answer.
// Behind counts the target's check-ins the branch has not seen — how
// far the target has moved since the branch last carried it. Zero
// means an update has nothing to bring in; the decision card offers
// the verb only when this says it would do something (RFC 0009).
func (ix *Index) Behind(branch, target string) (int, error) {
	if branch == "" || target == "" || branch == target {
		return 0, nil
	}
	branchTip, err := ix.branchTip(branch)
	if err != nil || branchTip == "" {
		return 0, err
	}
	targetTip, err := ix.branchTip(target)
	if err != nil || targetTip == "" {
		return 0, err
	}
	// Ancestors of the branch tip (bounded: a branch that diverged more
	// than 500 deep still reads as behind, just not exactly how far).
	rows, err := ix.db.Query(`
		WITH RECURSIVE seen(hash, depth) AS (
			SELECT ?, 0
			UNION
			SELECT p.parent, s.depth + 1
				FROM checkin_parent p
				JOIN seen s ON p.child = s.hash
				WHERE s.depth < 500
		)
		SELECT hash FROM seen`, branchTip)
	if err != nil {
		return 0, err
	}
	ancestors := map[string]bool{}
	for rows.Next() {
		var h string
		if err := rows.Scan(&h); err != nil {
			rows.Close()
			return 0, err
		}
		ancestors[h] = true
	}
	rows.Close()
	if err := rows.Err(); err != nil {
		return 0, err
	}
	// Walk the target back from its tip, counting until the branch has
	// seen it. The walk stays on reachable parents, not branch labels,
	// so merges from elsewhere count as the movement they are.
	behind := 0
	frontier := []string{targetTip}
	visited := map[string]bool{}
	for len(frontier) > 0 && behind <= 500 {
		next := []string{}
		for _, h := range frontier {
			if visited[h] || ancestors[h] {
				continue
			}
			visited[h] = true
			behind++
			ps, err := ix.ParentsOf(h)
			if err != nil {
				return behind, err
			}
			next = append(next, ps...)
		}
		frontier = next
	}
	return behind, nil
}

// VerdictOf reads one check-in's recorded CI verdict — "ci-pass",
// "ci-fail", or "" — the per-check-in reading VerdictsForLandings
// gives per branch (RFC 0009 §verdict-tags, carry-forward's guard).
func (ix *Index) VerdictOf(checkin string) (string, error) {
	if checkin == "" {
		return "", nil
	}
	st, err := ix.tagState([]string{checkin})
	if err != nil {
		return "", err
	}
	return st[checkin].ci, nil
}

// ParentsOf lists a check-in's parents — for a merge, the two tips it
// joined, which is how carry-forward reconstructs the tested pair.
func (ix *Index) ParentsOf(checkin string) ([]string, error) {
	rows, err := ix.db.Query(`SELECT parent FROM checkin_parent WHERE child = ?`, checkin)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []string
	for rows.Next() {
		var p string
		if err := rows.Scan(&p); err != nil {
			return nil, err
		}
		out = append(out, p)
	}
	return out, rows.Err()
}

func (ix *Index) OpenLandingOn(branch string) (LandingSummary, bool, error) {
	var l LandingSummary
	err := ix.db.QueryRow(`SELECT id, branch, target, title, author, opened, updated, state, settled_by, superseded_by, claimed_by
		FROM landing WHERE branch = ? AND state = 'open' AND superseded_by = ''
		ORDER BY opened DESC, id DESC LIMIT 1`, branch).Scan(&l.ID, &l.Branch, &l.Target,
		&l.Title, &l.Author, &l.Opened, &l.Updated, &l.State, &l.SettledBy, &l.SupersededBy, &l.ClaimedBy)
	if err == sql.ErrNoRows {
		return LandingSummary{}, false, nil
	}
	if err != nil {
		return LandingSummary{}, false, err
	}
	return l, true, nil
}

// BranchCommit is one check-in a landing proposes.
type BranchCommit struct {
	Hash    string `json:"hash"`
	Date    string `json:"date"`
	User    string `json:"user"`
	Comment string `json:"comment"`
}

// BranchCommits returns a branch's own check-ins in ANCESTRY order,
// parents before children.
//
// The landing page used to list them through the timeline with a branch
// filter, which orders by (date DESC, hash DESC). Two check-ins made in
// the same second tie on date and then order by hash, which has nothing
// to do with which is the parent — so a branch's own commits rendered
// in an order that is arbitrary with respect to ancestry. Commits land
// back-to-back routinely here, so this is not a rare shape, and it is
// exactly the quiet kind of wrong a reviewer would trust.
//
// zit hit the same bug in `landing show` and its fix is the one worth
// inheriting: stop hand-rolling the order. The verb owns no ancestry
// ordering of its own; the DAG does.
//
// So this walks parents from the branch tip and orders by depth. A
// check-in's depth is its LONGEST path back from the tip, which is what
// makes a parent always deeper than its child even where the branch
// forked and rejoined. Dates break ties between unrelated commits at
// the same depth, where any order is as good as another, and never
// between a parent and its child.
//
// The walk stops where the branch does: a parent on another branch is
// the point the proposal diverged, and everything beyond it is shared
// history the target already has.
// BranchTip names a branch's newest check-in — the subject CI runs
// and external checks attach to (RFC 0009). Exported because taking
// the last element of a LIMITed BranchCommits silently answers with
// the wrong commit once a branch outgrows the limit.
func (ix *Index) BranchTip(branch string) (string, error) {
	return ix.branchTip(branch)
}

func (ix *Index) BranchCommits(branch string, limit int) ([]BranchCommit, error) {
	if branch == "" {
		return nil, nil
	}
	if limit <= 0 {
		limit = 50
	}
	tip, err := ix.branchTip(branch)
	if err != nil || tip == "" {
		return nil, err
	}
	rows, err := ix.db.Query(`
		WITH RECURSIVE walk(hash, depth) AS (
			SELECT ?, 0
			UNION
			SELECT p.parent, w.depth + 1
				FROM checkin_parent p
				JOIN walk w ON p.child = w.hash
				JOIN checkin c ON c.hash = p.parent
				WHERE c.branch = ?
		)
		SELECT c.hash, c.date, c.user, c.comment, max(w.depth) AS d
			FROM walk w JOIN checkin c ON c.hash = w.hash
			WHERE c.branch = ?
			GROUP BY c.hash
			ORDER BY d DESC, c.date, c.hash
			LIMIT ?`, tip, branch, branch, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []BranchCommit
	for rows.Next() {
		var c BranchCommit
		var depth int
		if err := rows.Scan(&c.Hash, &c.Date, &c.User, &c.Comment, &depth); err != nil {
			return nil, err
		}
		out = append(out, c)
	}
	return out, rows.Err()
}

// branchTip is the check-in on a branch that nothing else on that
// branch calls a parent.
//
// Not "the newest by date", which is the same mistake one level up:
// two tips tying on a second would pick by hash. A tip is a structural
// property — a leaf within the branch — and only a branch that genuinely
// forked has more than one, where the newest is the honest guess.
func (ix *Index) branchTip(branch string) (string, error) {
	var tip string
	err := ix.db.QueryRow(`SELECT c.hash FROM checkin c
		WHERE c.branch = ? AND NOT EXISTS (
			SELECT 1 FROM checkin_parent p JOIN checkin k ON k.hash = p.child
			WHERE p.parent = c.hash AND k.branch = ?)
		ORDER BY c.date DESC, c.hash DESC LIMIT 1`, branch, branch).Scan(&tip)
	if err == sql.ErrNoRows {
		return "", nil
	}
	return tip, err
}

// LandingMerge is the check-in a landing produced.
type LandingMerge struct {
	Checkin string `json:"checkin"`
	Date    string `json:"date"`
	User    string `json:"user"`
	Comment string `json:"comment"`
}

// MergesForLandings resolves each landing to the check-in that merged
// it, for the landings named. Landings that never landed are absent.
//
// landing.settled_by holds the reply that DECIDED the state — a forum
// post hash, not a commit — so it was read as meaning nothing connects
// a landing to the check-in it produced, and the durable fix was
// assumed to be upstream. It is not: the answer is already in the DAG.
// A merge check-in's P card lists its primary parent first and the
// branches it merged after, so ord > 0 IS the branch tip that landed,
// and the landing knows which branch it proposed.
//
// That is a structural fact zit wrote when it merged, not a guess.
// Matching on comment text would have been a guess — those matched only
// because the accept message and the merge message were written the
// same way by habit — and branch-plus-time is a heuristic that is
// eventually wrong.
//
// The search is bounded by when the landing was OPENED, not when it was
// accepted. The merge lands before the state artifact recording it is
// written — pager-shape merged at 12:46:55 and its verdict was filed at
// 12:49:51 — so a verdict bound silently loses exactly the check-ins
// this exists to find. A proposal cannot be landed before it is made,
// which is the bound that actually holds.
//
// The remaining ambiguity is a branch landed more than once; the
// earliest qualifying merge wins, which is the one that first landing
// produced.
func (ix *Index) MergesForLandings(ids []string) (map[string]LandingMerge, error) {
	out := map[string]LandingMerge{}
	if len(ids) == 0 {
		return out, nil
	}
	stmt, err := ix.db.Prepare(`SELECT c.hash, c.date, c.user, c.comment
		FROM checkin c
		JOIN checkin_parent p ON p.child = c.hash AND p.ord > 0
		JOIN checkin tip ON tip.hash = p.parent
		WHERE tip.branch = ? AND c.branch = ? AND c.date >= ?
		ORDER BY c.date, c.hash LIMIT 1`)
	if err != nil {
		return nil, err
	}
	defer stmt.Close()
	for _, id := range ids {
		var branch, target, opened string
		err := ix.db.QueryRow(`SELECT l.branch, l.target, l.opened
			FROM landing l WHERE l.id = ? AND l.state = 'accepted'`,
			id).Scan(&branch, &target, &opened)
		if err == sql.ErrNoRows {
			continue // open, rejected, or unknown: nothing landed
		}
		if err != nil {
			return nil, err
		}
		var m LandingMerge
		err = stmt.QueryRow(branch, target, opened).Scan(&m.Checkin, &m.Date, &m.User, &m.Comment)
		if err == sql.ErrNoRows {
			continue
		}
		if err != nil {
			return nil, err
		}
		out[id] = m
	}
	return out, nil
}

// attachLandings decorates the MERGE check-ins in a timeline page with
// the landing each one settled, and who approved it.
//
// Evaluated for the returned rows only, and only for the merges among
// them — the same rule the page's other per-row extras follow. A
// timeline of check-ins on a forge with no landings pays one nothing.
func (ix *Index) attachLandings(out []TimelineEntry) error {
	var merges []int
	for i := range out {
		if out[i].Kind == "ci" && out[i].IsMerge {
			merges = append(merges, i)
		}
	}
	if len(merges) == 0 {
		return nil
	}
	// The approver is whoever posted the accept; failing that — a
	// merge pushed from a clone with no reply — whoever wrote the merge.
	stmt, err := ix.db.Prepare(`SELECT l.id, l.title,
			COALESCE(NULLIF((SELECT f.user FROM forum_post f WHERE f.hash = l.settled_by), ''), c.user)
		FROM checkin c
		JOIN checkin_parent p ON p.child = c.hash AND p.ord > 0
		JOIN checkin tip ON tip.hash = p.parent
		JOIN landing l ON l.branch = tip.branch AND l.target = c.branch
			AND l.state = 'accepted' AND c.date >= l.opened
		WHERE c.hash = ?
		ORDER BY l.updated DESC LIMIT 1`)
	if err != nil {
		return err
	}
	defer stmt.Close()
	for _, i := range merges {
		var id, title, approver string
		err := stmt.QueryRow(out[i].Hash).Scan(&id, &title, &approver)
		if err == sql.ErrNoRows {
			continue // a merge somebody made by hand
		}
		if err != nil {
			return err
		}
		out[i].Landing, out[i].LandingTitle, out[i].LandingApprover = id, title, approver
	}
	return nil
}

// backfillLandingEvents re-files landing posts that an older index filed
// as forum events.
//
// The classification moved from the forum trigger into the fold, so a
// store folded before that change carries "Forum: " rows for every
// landing comment and verdict it already holds. Without this, the
// timeline stays wrong until somebody happens to reindex — and derived
// data that is only correct after a manual step is not derived data,
// it is a chore.
//
// Idempotent and cheap to skip: the count is an indexed lookup, and a
// store with nothing to fix pays only for that.
func (ix *Index) backfillLandingEvents() error {
	var stale int
	if err := ix.db.QueryRow(`SELECT count(*) FROM event e
		JOIN forum_post p ON p.hash = e.hash
		WHERE e.kind = 'f' AND p.thread IN (SELECT id FROM landing)`).Scan(&stale); err != nil {
		return err
	}
	if stale == 0 {
		return nil
	}
	rows, err := ix.db.Query(`SELECT id FROM landing`)
	if err != nil {
		return err
	}
	var roots []string
	for rows.Next() {
		var h string
		if err := rows.Scan(&h); err != nil {
			rows.Close()
			return err
		}
		roots = append(roots, h)
	}
	rows.Close()
	if err := rows.Err(); err != nil {
		return err
	}
	tx, err := ix.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback()
	// foldLanding rather than refileLandingEvents directly: it is the
	// one place that decides which reply is a verdict, and a backfill
	// that re-derived that rule beside it would be a second answer to
	// the same question.
	for _, root := range roots {
		if err := foldLanding(tx, root); err != nil {
			return err
		}
	}
	return tx.Commit()
}

// LandingSummary is the list-surface row: everything the queue and the
// detail header need, without a second query or a DAG walk.
type LandingSummary struct {
	ID      string `json:"id"`
	Branch  string `json:"branch"`
	Target  string `json:"target"`
	Title   string `json:"title"`
	Author  string `json:"author"`
	Opened  string `json:"opened"`
	Updated string `json:"updated"`
	State   string `json:"state"`
	// SettledBy is the reply that decided State; empty while open.
	SettledBy string `json:"settled_by,omitempty"`
	// Replies counts the thread's remarks: every post but the opener,
	// minus the state-transition posts, which are events rather than
	// things anyone said.
	Replies int `json:"replies"`
	// SupersededBy names the landing this one defers to when two are
	// open on the same branch. Empty is the ordinary case.
	SupersededBy string `json:"superseded_by,omitempty"`
	// ClaimedBy is a reply that asserted acceptance while nothing was
	// merged. The record is still open; the page says a claim was made.
	ClaimedBy string `json:"claimed_by,omitempty"`
	// CI is the branch tip's recorded verdict — "ci-pass", "ci-fail",
	// or empty when no run has written one. Not stored on the landing:
	// filled by the reader from the tag, so it cannot go stale against
	// the branch it describes.
	CI string `json:"ci,omitempty"`
	// Stack, Position and Depth are RFC 0006's reading of an open
	// landing: the base's id, this landing's rung counted from 1 at the
	// base, and the stack's height. Derived at read time by
	// AttachStrata, never stored. Broken names a chain that cannot be
	// walked: a cycle, or a rejected base.
	Stack    string `json:"stack,omitempty"`
	Position int    `json:"position,omitempty"`
	Depth    int    `json:"depth,omitempty"`
	Broken   string `json:"broken,omitempty"`
}

// Landings lists landings, newest activity first. state filters to one of
// open/accepted/rejected; empty returns every landing.
//
// limit of 0 means no limit — the caller is a page that already knows how
// many rows it wants, and a silent default would truncate a listing that
// looked complete.
func (ix *Index) Landings(state string, limit int) ([]LandingSummary, error) {
	q := `SELECT id, branch, target, title, author, opened, updated, state, settled_by, superseded_by, claimed_by,
		(SELECT count(*) FROM forum_post p
			WHERE p.thread = landing.id AND p.hash <> landing.id
			  AND p.body NOT LIKE ?)
		FROM landing`
	// The marker holds no LIKE wildcards, so a bare prefix pattern is exact.
	args := []any{landingStateMarker + "%"}
	if state != "" {
		q += ` WHERE state = ?`
		args = append(args, state)
		// The open queue is a work list, and a superseded proposal is
		// not work: its branch already has a current landing. It stays
		// reachable, and the unfiltered listing still shows it.
		if state == "open" {
			q += ` AND superseded_by = ''`
		}
	}
	q += ` ORDER BY updated DESC, id DESC`
	if limit > 0 {
		q += ` LIMIT ?`
		args = append(args, limit)
	}
	rows, err := ix.db.Query(q, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []LandingSummary
	for rows.Next() {
		var l LandingSummary
		if err := rows.Scan(&l.ID, &l.Branch, &l.Target, &l.Title, &l.Author,
			&l.Opened, &l.Updated, &l.State, &l.SettledBy, &l.SupersededBy, &l.ClaimedBy,
			&l.Replies); err != nil {
			return nil, err
		}
		out = append(out, l)
	}
	return out, rows.Err()
}

// Landing returns one landing by id, or ok=false when no such record is
// indexed. Prefix matching belongs to the caller's hash resolution, not
// here: this table's key is the full thread-root hash.
func (ix *Index) Landing(id string) (LandingSummary, bool, error) {
	var l LandingSummary
	err := ix.db.QueryRow(`SELECT id, branch, target, title, author, opened, updated, state, settled_by, superseded_by, claimed_by
		FROM landing WHERE id = ?`, id).Scan(&l.ID, &l.Branch, &l.Target, &l.Title,
		&l.Author, &l.Opened, &l.Updated, &l.State, &l.SettledBy, &l.SupersededBy, &l.ClaimedBy)
	if err == sql.ErrNoRows {
		return LandingSummary{}, false, nil
	}
	if err != nil {
		return LandingSummary{}, false, err
	}
	return l, true, nil
}