package memorysearch

import (
	"context"
	"errors"
	"fmt"
	"io/fs"
	"log/slog"
	"os"
	"path/filepath"
	"sort"
	"sync"
	"time"
)

// Reconciler is the daemon-scoped per-agent owner of a Memory search
// derivative. It owns the cross-process lock, the single-writer
// goroutine, and the pending/current pointer pair. Trigger sources
// (watcher ChangeListener, daemon startup, periodic timer, tool-time
// freshness) all funnel into Submit; the goroutine coalesces
// concurrently-pending submissions into one reconcile pass.
//
// Lifecycle: a Reconciler is constructed for one binding and lives as
// long as the daemon owns that binding. It is NOT tied to an interactive
// session — sessions come and go, but the derivative must stay current
// for offline agents too, so trigger sources independent of any session
// (startup, periodic) must be wired by the daemon, not by the session.
//
// Trust boundary: all paths are derived from ReconcilerOptions.Resolver
// and ReconcilerOptions.Binding. The Reconciler never accepts a path
// from a Submit caller, a ChangeListener invocation, or a Current query.
type Reconciler struct {
	opts   ReconcilerOptions
	logger *slog.Logger
	clock  func() time.Time

	// lock is the cross-process flock held for the lifetime of this
	// Reconciler. Acquired non-blocking at Start; a second Desktop
	// instance fails fast to degraded rather than wait.
	lock *OwnerLock

	// derivativeRoot, resolvedRoot, generationsDir are resolver-derived
	// and frozen at construction. No method may accept a path that
	// overrides them.
	derivativeRoot string
	resolvedRoot   string
	generationsDir string

	// trigger is the single-slot coalescing channel. Submit does a non-
	// blocking send; the goroutine reads at most one per reconcile pass.
	// A send while a pass is in flight is dropped (coalesced into the
	// current pass), and after the pass the goroutine drains any further
	// pending trigger with one extra pass.
	trigger chan struct{}

	// ctx is the owned context that drives every reconcile pass. Close
	// cancels it; in-flight Scan/Build/Chunk calls observe the
	// cancellation and unblock. The goroutine selects on ctx.Done so a
	// Close while idle also wakes it.
	ctx context.Context
	// cancel cancels ctx. Guarded by stateMu. Always paired with ctx.
	cancel context.CancelFunc

	// finished is closed by the goroutine when it has actually exited.
	// Close waits on this WITHOUT a timeout before releasing the
	// cross-process lock: releasing first would let a second owner start
	// writing to the same derivative while the old goroutine is still
	// mid-pass. There is no backoff — a reconciler whose writer refuses
	// to exit is a bug, and timing out would re-introduce the
	// concurrent-writer hazard the lock is supposed to prevent.
	finished  chan struct{}
	trashWake chan struct{}
	trashStop chan struct{}
	trashDone chan struct{}

	// pools owns every open Generation handle the query path borrows
	// from. Commit B replaces the previous "Current always opens a fresh
	// Bleve handle per call" behavior with a basename-keyed pool so
	// concurrent queries against the same published generation share
	// one Bleve reader, and so GC of an older generation waits for
	// in-flight queries to drain before removing the directory.
	pools *poolRegistry
	// lifecycle persists binding/generation state needed by retention and
	// first-successful-query acceleration in later Task 7 commits.
	lifecycle   *lifecycleStore
	retentionMu sync.Mutex
	// namespaceMu linearizes only current pointer swaps with GC/reclaim's
	// final current recheck + pool retirement + rename. Lock order:
	// retentionMu -> namespaceMu -> poolRegistry.mu -> generationPool.mu.
	// lifecycle.mu is never held with namespaceMu.
	namespaceMu sync.Mutex
	// pressureMu serializes admission/reclaim snapshots with the transition
	// into an actual Build call. buildsInFlight is read only under pressureMu;
	// while non-zero, emergency reclaim conservatively skips every markerless
	// generation so an already-running build is never interrupted.
	pressureMu     sync.Mutex
	buildsInFlight int

	// sourceMu is the authoritative source-access gate. Unknown access keeps
	// the last trusted generation readable but prevents new reconcile work
	// and prevents an already-running pass from publishing after suspension.
	// Lock order: sourceMu -> namespaceMu.
	sourceMu      sync.RWMutex
	sourceTrusted bool

	stateMu         sync.Mutex
	started         bool
	closed          bool
	dirtyEpoch      uint64
	ackEpoch        uint64
	forceEpoch      uint64
	currentBasename string
	closeOnce       sync.Once
	closeErr        error
}

// ReconcilerOptions configures NewReconciler. The caller supplies a
// validated binding plus the same resolver used to construct it; the
// Reconciler re-validates the binding at Start.
type ReconcilerOptions struct {
	// Binding is the validated binding the Reconciler owns.
	Binding *Binding
	// Resolver is the same resolver used to construct Binding. The
	// Reconciler re-validates Binding against it at Start.
	Resolver AgentWorkDirResolver
	// Scan reads the source clone. Production wires the existing
	// memorysearch.Scan; tests inject a fake to simulate drift between
	// pre-build and post-build snapshots.
	Scan func(ctx context.Context, root string) (SourceSnapshot, ScanStatus, error)
	// Logger receives lifecycle events. Nil falls back to slog.Default().
	Logger *slog.Logger
	// Clock returns the current time. Tests inject a fake; production
	// leaves nil for time.Now.
	Clock func() time.Time
	// BuildFunc overrides the package-level Build function. Nil falls
	// back to Build; tests inject a fake to observe build-path timing
	// (blocking, failure injection, limiter contention). The fake must
	// honor ctx cancellation the same way Build does.
	BuildFunc func(ctx context.Context, opts BuildOptions) (*Generation, error)
	// BuildLimiter gates the Build call against a daemon-wide budget.
	// Nil means no limit; existing dispatcher wiring (which does not
	// share builds across bindings) leaves nil to preserve current
	// behavior. The Manager injects one shared limiter per daemon so
	// concurrent reconcilers across different agents cannot each burn
	// a full CPU on a fresh build.
	BuildLimiter BuildLimiter
	// DiskUsageProvider and PressurePolicy are injected by Manager. Direct
	// Reconciler construction defaults both for backward compatibility.
	DiskUsageProvider DiskUsageProvider
	PressurePolicy    PressurePolicy
}

// NewReconciler constructs a Reconciler. It validates options and
// pre-computes resolver-derived paths but does NOT acquire the lock or
// start the goroutine. Call Start to do so.
func NewReconciler(opts ReconcilerOptions) (*Reconciler, error) {
	if opts.Binding == nil {
		return nil, fmt.Errorf("%w: binding is required", ErrInvalidBuildOptions)
	}
	if opts.Resolver == nil {
		return nil, fmt.Errorf("%w: resolver is required", ErrInvalidBuildOptions)
	}
	if opts.Scan == nil {
		return nil, fmt.Errorf("%w: Scan is required", ErrInvalidBuildOptions)
	}
	if err := opts.Binding.ValidateWith(opts.Resolver); err != nil {
		return nil, fmt.Errorf("%w: binding failed resolver validation: %v",
			ErrInvalidBuildOptions, err)
	}
	workDir := opts.Resolver(opts.Binding.LocalAgentID)
	if workDir == "" {
		return nil, fmt.Errorf("%w: resolver returned empty work dir for %q",
			ErrInvalidBuildOptions, opts.Binding.LocalAgentID)
	}
	logger := opts.Logger
	if logger == nil {
		logger = slog.Default()
	}
	clock := opts.Clock
	if clock == nil {
		clock = time.Now
	}
	policy, err := normalizePressurePolicy(opts.PressurePolicy)
	if err != nil {
		return nil, err
	}
	opts.PressurePolicy = policy
	if opts.DiskUsageProvider == nil {
		opts.DiskUsageProvider = osDiskUsageProvider{}
	}
	reconciler := &Reconciler{
		opts:           opts,
		logger:         logger,
		clock:          clock,
		derivativeRoot: DerivativeRootFromWorkDir(workDir),
		resolvedRoot:   filepath.Join(workDir, memoryCloneDir),
		generationsDir: filepath.Join(DerivativeRootFromWorkDir(workDir), generationsDirName),
		trigger:        make(chan struct{}, 1),
		finished:       make(chan struct{}),
		trashWake:      make(chan struct{}, 1),
		trashStop:      make(chan struct{}),
		trashDone:      make(chan struct{}),
		pools:          newPoolRegistry(opts.Binding, opts.Resolver),
		sourceTrusted:  true,
	}
	reconciler.lifecycle = newLifecycleStore(
		reconciler.derivativeRoot,
		opts.Binding,
		reconciler.clock,
	)
	return reconciler, nil
}

// Start acquires the cross-process owner lock (non-blocking; returns
// ErrOwnerLockBusy if another Desktop instance holds it), runs pending
// recovery, and launches the single-writer goroutine. The first
// reconcile pass is enqueued automatically so a freshly-started daemon
// converges immediately without waiting for an external trigger.
//
// Start is idempotent for the lifetime of a Reconciler: a second call on
// a Reconciler whose first Start succeeded returns nil without
// re-acquiring the lock or re-launching the goroutine. After Close,
// Start returns ErrClosed: the caller must construct a new Reconciler.
// This avoids the "Start after Close" footgun where the second Start
// would race with the closeOnce cleanup.
//
// If recovery fails, the lock is released and the owned context is torn
// down before returning; the caller observes the recovery error and
// must investigate before retrying Start (which will require a new
// Reconciler since this one is left in the "started but unrecoverable"
// state — the next Start on this instance returns ErrClosed).
func (r *Reconciler) Start(ctx context.Context) error {
	r.stateMu.Lock()
	if r.closed {
		r.stateMu.Unlock()
		return fmt.Errorf("%w: reconciler is closed", ErrClosed)
	}
	if r.started {
		r.stateMu.Unlock()
		return nil
	}
	lock, err := TryAcquireOwnerLock(r.derivativeRoot)
	if err != nil {
		r.stateMu.Unlock()
		return err
	}
	r.lock = lock
	r.ctx, r.cancel = context.WithCancel(context.Background())
	r.stateMu.Unlock()

	if err := r.recoverPending(ctx); err != nil {
		// Tear down everything we just installed: cancel the owned ctx
		// (no goroutine exists yet, but the cancel is still valid),
		// release the lock, and mark closed so any subsequent Start
		// returns ErrClosed instead of attempting a half-initialized
		// second try.
		r.stateMu.Lock()
		r.cancel()
		r.ctx, r.cancel = nil, nil
		if r.lock != nil {
			r.lock.Release()
			r.lock = nil
		}
		r.closed = true
		r.stateMu.Unlock()
		return fmt.Errorf("memory search: recover pending: %w", err)
	}
	if current, err := loadCurrent(r.derivativeRoot); err == nil &&
		current.BindingFingerprint == r.opts.Binding.Fingerprint() {
		r.stateMu.Lock()
		r.currentBasename = current.GenerationBasename
		r.stateMu.Unlock()
		if err := r.lifecycle.ensureCurrent(*current); err != nil {
			r.stateMu.Lock()
			r.cancel()
			r.ctx, r.cancel = nil, nil
			if r.lock != nil {
				r.lock.Release()
				r.lock = nil
			}
			r.closed = true
			r.stateMu.Unlock()
			return fmt.Errorf("memory search: bootstrap lifecycle: %w", err)
		}
	}
	if err := sweepTrash(r.derivativeRoot); err != nil {
		r.logger.WarnContext(ctx, "memory search: trash startup sweep failed",
			"error", err)
	}

	r.stateMu.Lock()
	r.started = true
	r.stateMu.Unlock()

	go r.agentteam()
	go r.trashLoop()
	// Enqueue the first reconcile so daemon startup converges without
	// waiting for the periodic timer or a watcher event.
	r.Submit(ctx)
	return nil
}

// CheckFreshness enqueues a reconcile pass without changing public state.
// Tool-time and periodic hints use this: a no-op scan must not make a healthy
// index appear stale. The writer marks refreshing only after Scan proves the
// source revision differs.
func (r *Reconciler) CheckFreshness(context.Context) {
	r.sourceMu.RLock()
	trusted := r.sourceTrusted
	r.sourceMu.RUnlock()
	r.stateMu.Lock()
	if r.closed {
		r.stateMu.Unlock()
		return
	}
	if !trusted {
		// Unknown access freezes derivative writes. SetSourceTrusted already
		// keeps refreshing=true; do not queue work.
		r.stateMu.Unlock()
		return
	}
	select {
	case r.trigger <- struct{}{}:
	default:
		// Coalesce: a pending trigger already waits; the next pass will
		// pick up the latest source state via Scan anyway.
	}
	r.stateMu.Unlock()
}

// MarkDirty records a known source change (watcher commit, source
// revalidation miss, or authoritative resume) and enqueues reconciliation.
// Failure keeps refreshing=true; only a successful no-op verification or
// publish clears it.
func (r *Reconciler) MarkDirty(ctx context.Context) {
	r.stateMu.Lock()
	if r.closed {
		r.stateMu.Unlock()
		return
	}
	r.dirtyEpoch++
	r.stateMu.Unlock()
	r.CheckFreshness(ctx)
}

// ForceRebuild requests a side-by-side generation even when the source
// revision is unchanged. The old current remains serviceable until the new
// generation passes Build/Open smoke and publish atomically swaps current.
func (r *Reconciler) ForceRebuild() {
	r.stateMu.Lock()
	if r.closed {
		r.stateMu.Unlock()
		return
	}
	r.dirtyEpoch++
	r.forceEpoch = r.dirtyEpoch
	r.stateMu.Unlock()
	r.CheckFreshness(context.Background())
}

// Submit remains the compatibility name for known-dirty callers.
func (r *Reconciler) Submit(ctx context.Context) {
	r.MarkDirty(ctx)
}

// SetSourceTrusted updates the authoritative source-access gate. Suspending
// preserves the last trusted generation for source-revalidated reads but
// prevents queued/in-flight work from publishing. Resuming enqueues one
// bounded reconcile pass to converge any source changes accumulated while
// access was unknown.
func (r *Reconciler) SetSourceTrusted(trusted bool) {
	r.sourceMu.Lock()
	changed := r.sourceTrusted != trusted
	r.sourceTrusted = trusted
	r.sourceMu.Unlock()

	r.stateMu.Lock()
	if r.closed {
		r.stateMu.Unlock()
		return
	}
	if !trusted {
		r.dirtyEpoch++
		r.stateMu.Unlock()
		return
	}
	r.stateMu.Unlock()
	if changed {
		r.MarkDirty(context.Background())
	}
}

func (r *Reconciler) sourceIsTrusted() bool {
	r.sourceMu.RLock()
	defer r.sourceMu.RUnlock()
	return r.sourceTrusted
}

// OnMemoryChanged satisfies memorywatcher.ChangeListener by calling
// Submit. It is the fast coalescing enqueue the watcher contract
// requires: it never blocks on the reconcile work itself.
func (r *Reconciler) OnMemoryChanged(context.Context) {
	r.MarkDirty(context.Background())
}

// agentteam is the single-writer goroutine. It serializes reconcile passes
// and coalesces concurrent triggers into one extra pass on completion.
// The goroutine selects on the owned ctx so a Close while idle wakes it
// immediately; a Close while a pass is in flight cancels the in-flight
// Scan/Build/Chunk calls so the pass returns promptly and the goroutine
// then exits.
func (r *Reconciler) agentteam() {
	defer close(r.finished)
	for {
		select {
		case <-r.ctx.Done():
			return
		case <-r.trigger:
			r.runPassWithDrain(r.ctx)
		}
	}
}

func (r *Reconciler) trashLoop() {
	defer close(r.trashDone)
	for {
		select {
		case <-r.trashStop:
			return
		case <-r.trashWake:
			if err := sweepTrash(r.derivativeRoot); err != nil {
				r.logger.Warn("memory search: trash sweep failed", "error", err)
			}
		}
	}
}

// runPassWithDrain runs at most two reconcile passes per wakeup. The
// second pass runs if EITHER the first pass hit ErrDirtyDuringBuild
// (transient drift; the source may have settled during the build) OR a
// trigger arrived during the first pass (coalesced into one extra
// pass). After two passes the reconciler returns to idle regardless of
// the second pass's outcome: a source clone that mutates faster
// than a build can complete would otherwise busy-agentteam the reconciler.
// The next external trigger (watcher event, periodic timer, tool-time
// freshness check) fires another wakeup.
//
// This bounds the worst case to two passes per external trigger burst,
// regardless of how fast the source is mutating.
func (r *Reconciler) runPassWithDrain(ctx context.Context) {
	ack, converged, err := r.runOnce(ctx)
	if err != nil {
		r.logger.WarnContext(ctx, "memory search: reconcile pass failed",
			"error", err)
	}
	// Decide whether to run a second pass.
	shouldRetry := errors.Is(err, ErrDirtyDuringBuild)
	if shouldRetry {
		// Drain any trigger that arrived during pass 1 so the retry
		// does not double-count it as a separate wakeup.
		select {
		case <-r.trigger:
		default:
		}
	} else {
		// No drift: run a second pass only if a trigger arrived
		// during pass 1 (coalesced into the drain).
		select {
		case <-r.trigger:
			shouldRetry = true
		default:
		}
	}
	if !shouldRetry {
		if converged {
			r.ackDirty(ack)
		}
		return
	}
	ack2, converged2, err := r.runOnce(ctx)
	if err != nil {
		r.logger.WarnContext(ctx, "memory search: reconcile drain pass failed",
			"error", err)
	}
	ack, converged = ack2, converged2
	// Drop any further stacked trigger so the next external wakeup
	// starts a fresh runPassWithDrain rather than observing a stale
	// pending signal.
	select {
	case <-r.trigger:
	default:
	}
	if converged {
		r.ackDirty(ack)
	}
}

func (r *Reconciler) ackDirty(epoch uint64) {
	r.stateMu.Lock()
	if epoch > r.ackEpoch {
		r.ackEpoch = epoch
	}
	r.stateMu.Unlock()
}

// runOnce runs a single reconcile pass. Steps:
//  1. Scan source clone.
//  2. Fail-closed on degraded or limited scan: keep previous generation
//     serviceable, return classified error, do NOT publish a partial
//     snapshot.
//  3. Compare with current pointer; no-op if revision matches.
//  4. Journal pending phase=scanning.
//  5. Chunk via confined reads (os.Root + readAndHash), re-verifying
//     each file's ContentHash; any drift returns ErrDirtyDuringBuild.
//  6. Journal pending phase=building.
//  7. Build a new generation (Task 4 Build).
//  8. Re-Scan; if revision drifted during Build, drop the candidate and
//     return ErrDirtyDuringBuild so the drain agentteam runs once more.
//  9. Journal pending phase=publishing with candidate basename.
//
// 10. Publish: atomic current-pointer swap.
// 11. Journal pending phase=clearing (durable).
// 12. GC older generations, keeping current + previous as rollback.
// 13. Clear pending.
func (r *Reconciler) runOnce(ctx context.Context) (uint64, bool, error) {
	r.stateMu.Lock()
	passEpoch := r.dirtyEpoch
	forceRebuild := r.forceEpoch > r.ackEpoch
	r.stateMu.Unlock()
	if err := ctx.Err(); err != nil {
		return passEpoch, false, err
	}
	if !r.sourceIsTrusted() {
		return passEpoch, false, ErrSourceAccessUnknown
	}

	snap, status, err := r.opts.Scan(ctx, r.resolvedRoot)
	if err != nil {
		return passEpoch, false, fmt.Errorf("scan: %w", err)
	}
	if status.Degraded {
		// Fail-closed: a degraded scan saw an I/O error or root-swap. The
		// returned snapshot may be missing files the previous generation
		// indexed; publishing it would silently drop content from search
		// results. Keep the previous current pointer serviceable and let
		// the next external trigger retry.
		return passEpoch, false, fmt.Errorf("%w: degraded=%v limited=%v admitted=%d skipped=%d",
			ErrScanDegraded, status.Degraded, status.Limited,
			status.Admitted, status.Skipped)
	}
	if status.Limited {
		// Fail-closed even when only the bounds cap fired: a limited scan
		// has truncated the file set, so the would-be published
		// generation would not index files beyond the cap. The previous
		// generation remains serviceable; an operator must raise the cap
		// or trim the source clone.
		return passEpoch, false, fmt.Errorf("%w: degraded=%v limited=%v admitted=%d skipped=%d",
			ErrScanLimited, status.Degraded, status.Limited,
			status.Admitted, status.Skipped)
	}

	// Skip if current already indexes the same revision AND the
	// referenced generation is actually serviceable. The identity check
	// alone is not enough: current.json is attacker-writable state in
	// the threat model, and a pointer that names a missing directory, a
	// directory whose BUILD_COMPLETE marker was deleted, or a directory
	// whose manifest was corrupted would otherwise leave the reconciler
	// permanently idle while Current() keeps returning ErrNotReady. We
	// verify serviceability before short-circuiting so the reconciler
	// converges on a real build rather than trusting a stale pointer.
	if !forceRebuild {
		if cur, err := loadCurrent(r.derivativeRoot); err == nil &&
			cur.BindingFingerprint == r.opts.Binding.Fingerprint() &&
			cur.SourceRevision == snap.Revision {
			if serviceErr := r.verifyCurrentServiceable(ctx, cur); serviceErr == nil {
				if err := r.lifecycle.ensureCurrent(*cur); err != nil {
					return passEpoch, false, fmt.Errorf("repair lifecycle for current generation: %w", err)
				}
				r.ackDirty(passEpoch)
				return passEpoch, true, nil
			} else {
				// Log diagnostics but DO NOT remove the old pointer. It is
				// overwritten atomically by the next publish.
				r.logger.WarnContext(ctx, "memory search: current pointer references unserviceable generation; forcing rebuild",
					"generation", cur.GenerationBasename, "reason", serviceErr)
			}
		}
	}
	// Scan has now proven the source revision differs (or current is
	// unserviceable). Expose stale before any chunk/build work can fail.
	r.stateMu.Lock()
	if r.dirtyEpoch == r.ackEpoch {
		r.dirtyEpoch++
	}
	passEpoch = r.dirtyEpoch
	r.stateMu.Unlock()

	startedAt := r.clock()
	prev := r.previousBasename()
	// previousBasename validates rollback serviceability. If the current
	// pointer names the same revision but failed the no-op smoke above,
	// do not allow an already-open pool for that corrupted generation to
	// remain queryable while its replacement builds.
	if prev == "" {
		if cur, err := loadCurrent(r.derivativeRoot); err == nil &&
			cur.BindingFingerprint == r.opts.Binding.Fingerprint() {
			r.pools.MarkRetire(cur.GenerationBasename)
		}
	}
	pending := Pending{
		BindingFingerprint: r.opts.Binding.Fingerprint(),
		TargetRevision:     snap.Revision,
		Phase:              PendingPhaseScanning,
		PreviousBasename:   prev,
		StartedAt:          startedAt,
		UpdatedAt:          startedAt,
	}
	if err := writePendingAtomic(r.derivativeRoot, pending); err != nil {
		return passEpoch, false, fmt.Errorf("write pending (scanning): %w", err)
	}

	chunks, err := r.snapshotChunks(ctx, snap)
	if err != nil {
		return passEpoch, false, fmt.Errorf("chunk: %w", err)
	}

	pending.Phase = PendingPhaseBuilding
	pending.UpdatedAt = r.clock()
	if err := writePendingAtomic(r.derivativeRoot, pending); err != nil {
		return passEpoch, false, fmt.Errorf("write pending (building): %w", err)
	}

	gen, err := r.runBuild(ctx, BuildOptions{
		Binding:  r.opts.Binding,
		Resolver: r.opts.Resolver,
		Snapshot: snap,
		Chunks:   chunks,
		Clock:    r.clock,
	})
	if err != nil {
		return passEpoch, false, fmt.Errorf("build: %w", err)
	}

	// Re-verify: source state may have advanced during Build. If it
	// has, drop the just-built candidate and re-run; never promote a
	// stale generation to current.
	freshSnap, freshStatus, err := r.opts.Scan(ctx, r.resolvedRoot)
	if err != nil {
		r.logger.WarnContext(ctx, "memory search: re-scan failed; leaving candidate for next pass",
			"error", err)
		// Best-effort cleanup of the orphan candidate; the orphan scan
		// at the next recovery will catch it if this fails.
		_ = r.removeCandidate(filepath.Base(gen.Dir()))
		return passEpoch, false, ErrDirtyDuringBuild
	}
	if freshStatus.Degraded || freshStatus.Limited || freshSnap.Revision != snap.Revision {
		r.logger.InfoContext(ctx, "memory search: source drifted or scan degraded during build; dropping candidate",
			"was", snap.Revision, "now", freshSnap.Revision,
			"degraded", freshStatus.Degraded, "limited", freshStatus.Limited)
		_ = r.removeCandidate(filepath.Base(gen.Dir()))
		return passEpoch, false, fmt.Errorf("%w: %s -> %s", ErrDirtyDuringBuild, snap.Revision, freshSnap.Revision)
	}

	// Publish: journal publishing, swap pointer, journal clearing, GC,
	// clear journal. The clearing-phase journal is durable BEFORE GC so
	// a crash during GC leaves recovery a clear record of which
	// generation to keep and which to sweep.
	pending.Phase = PendingPhasePublishing
	pending.CandidateBasename = filepath.Base(gen.Dir())
	pending.UpdatedAt = r.clock()
	if err := writePendingAtomic(r.derivativeRoot, pending); err != nil {
		return passEpoch, false, fmt.Errorf("write pending (publishing): %w", err)
	}

	current, err := r.publish(ctx, gen, snap.Revision)
	if err != nil {
		return passEpoch, false, fmt.Errorf("publish: %w", err)
	}
	r.stateMu.Lock()
	r.currentBasename = current.GenerationBasename
	r.stateMu.Unlock()
	// Ack exactly the epoch this pass scanned. A watcher MarkDirty that
	// arrives after the fresh re-scan increments dirtyEpoch beyond passEpoch
	// and therefore remains stale; bookkeeping after the durable swap does
	// not delay readiness for the published snapshot.
	r.ackDirty(passEpoch)
	r.stateMu.Lock()
	if r.forceEpoch <= passEpoch {
		r.forceEpoch = 0
	}
	r.stateMu.Unlock()
	if err := r.lifecycle.recordPublish(current, prev); err != nil {
		return passEpoch, false, fmt.Errorf("record lifecycle after publish: %w", err)
	}

	// Commit B: retire the previously published pool so borrowers that
	// hold wrappers for the old basename stop accepting new queries.
	// The old basename was captured in `prev` before chunking began;
	// MarkRetire only after publish is durable (writeCurrentAtomic +
	// dir fsync inside r.publish), otherwise a crash could leave the
	// old pool retired with no current pointer to fall back to. Pools
	// with active borrowers transition to poolRetiring; the underlying
	// Bleve handle closes when the last borrower releases (or when
	// registry Close drains on shutdown).
	if prev != "" {
		r.pools.MarkRetire(prev)
	}

	// Switch the journal to clearing BEFORE running GC. If the process
	// crashes here after the publish but before GC, recovery sees
	// phase=clearing and finishes the sweep. Without this journal entry
	// the same crash would leave stale generations on disk with no
	// record that GC was pending; the next same-revision pass would
	// no-op and the stale dirs would never be swept.
	pending.Phase = PendingPhaseClearing
	pending.UpdatedAt = r.clock()
	if err := writePendingAtomic(r.derivativeRoot, pending); err != nil {
		return passEpoch, false, fmt.Errorf("write pending (clearing): %w", err)
	}

	if err := r.gcOlderGenerations(ctx, pending.CandidateBasename, prev); err != nil {
		// GC failure is non-fatal: current is published, the journal is
		// in phase=clearing, and the next reconcile pass will re-run GC
		// via the orphan scan. Log and proceed to clear the journal so
		// the publish is not rolled back by recovery misinterpreting a
		// stale publishing entry.
		r.logger.WarnContext(ctx, "memory search: gc failed (non-fatal)",
			"error", err)
	}
	if err := clearPending(r.derivativeRoot); err != nil {
		return passEpoch, false, fmt.Errorf("clear pending: %w", err)
	}
	return passEpoch, true, nil
}

// runBuild invokes the configured Build function (BuildFunc override or
// the package-level Build) and gates it through the optional BuildLimiter.
// The limiter wraps ONLY the actual build call so the same-revision no-op
// path, scan, chunk read, publish, and GC remain budget-free. This is
// the single point where Manager-wide build concurrency is enforced.
//
// Limiter contract: Acquire is called first; on non-nil error (ctx cancel
// or shutdown) the function returns WITHOUT calling Release — the caller
// must not call it either. On success the defer arms Release on every
// subsequent exit path including build error and ctx cancellation during
// Build itself.
func (r *Reconciler) runBuild(ctx context.Context, opts BuildOptions) (*Generation, error) {
	buildFn := r.opts.BuildFunc
	if buildFn == nil {
		buildFn = Build
	}
	hard, pressureErr := r.RelieveDiskPressure(ctx)
	if pressureErr != nil {
		// Measurement is advisory. Fail open and let the actual build I/O
		// report an actionable filesystem error rather than disabling search
		// because a platform usage probe was unavailable.
		r.logger.WarnContext(ctx, "memorysearch: disk pressure measurement failed; allowing build",
			"error", pressureErr)
	} else if hard {
		return nil, ErrBuildDiskPressure
	}
	if r.opts.BuildLimiter == nil {
		return r.invokeBuild(buildFn, ctx, opts)
	}
	if err := r.opts.BuildLimiter.Acquire(ctx); err != nil {
		return nil, err
	}
	defer r.opts.BuildLimiter.Release()
	return r.invokeBuild(buildFn, ctx, opts)
}

func (r *Reconciler) invokeBuild(
	buildFn func(context.Context, BuildOptions) (*Generation, error),
	ctx context.Context,
	opts BuildOptions,
) (*Generation, error) {
	r.pressureMu.Lock()
	r.buildsInFlight++
	r.pressureMu.Unlock()
	defer func() {
		r.pressureMu.Lock()
		r.buildsInFlight--
		r.pressureMu.Unlock()
	}()
	return buildFn(ctx, opts)
}

// removeCandidate removes a generation directory by basename. The
// basename is validated before any path construction so a forged
// pointer cannot turn this into a delete of arbitrary disk state. The
// remove is best-effort: callers treat failure as "GC will catch it
// later" and the orphan scan at next recovery will retry.
func (r *Reconciler) removeCandidate(basename string) error {
	if err := validateBasename(basename); err != nil {
		return err
	}
	return r.trashGeneration(basename)
}

func (r *Reconciler) trashGeneration(basename string) error {
	source := filepath.Join(r.generationsDir, basename)
	trashed, err := moveGenerationToTrash(r.derivativeRoot, source, r.clock())
	if err != nil {
		return err
	}
	if trashed != "" {
		select {
		case r.trashWake <- struct{}{}:
		default:
		}
	}
	return nil
}

// publish writes the crash-safe current pointer. Idempotent: writing
// the same pointer twice is harmless. Recovery calls this directly when
// it finds a publishing-phase journal whose candidate has BUILD_COMPLETE.
func (r *Reconciler) publish(ctx context.Context, gen *Generation, revision string) (Current, error) {
	c := Current{
		BindingFingerprint: r.opts.Binding.Fingerprint(),
		GenerationBasename: filepath.Base(gen.Dir()),
		SourceRevision:     revision,
		PublishedAt:        r.clock(),
	}
	// Suspension linearizes against publication under sourceMu. Once
	// SetSourceTrusted(false) returns, no pass can swap current.json until a
	// later resume.
	r.sourceMu.RLock()
	defer r.sourceMu.RUnlock()
	if !r.sourceTrusted {
		return Current{}, ErrSourceAccessUnknown
	}
	r.namespaceMu.Lock()
	err := writeCurrentAtomic(r.derivativeRoot, c)
	r.namespaceMu.Unlock()
	if err != nil {
		return Current{}, err
	}
	r.logger.InfoContext(ctx, "memory search: published",
		"generation", c.GenerationBasename,
		"revision", revision)
	return c, nil
}

// previousBasename returns the current pointer's generation basename,
// or empty if no current exists. Used to seed the rollback target. The
// value is validated on load: a corrupted current.json with a traversal
// basename, OR a pointer at an unserviceable generation (missing dir,
// missing marker, corrupt manifest), is treated as "no previous" rather
// than propagated into the pending journal. Returning empty for the
// unserviceable case is what lets GC sweep a broken generation after a
// rebuild publishes a replacement — otherwise the broken dir would be
// protected as a "rollback target" forever.
func (r *Reconciler) previousBasename() string {
	c, err := loadCurrent(r.derivativeRoot)
	if err != nil {
		return ""
	}
	if err := validateBasename(c.GenerationBasename); err != nil {
		r.logger.WarnContext(context.Background(), "memory search: current pointer has invalid basename; ignoring",
			"basename", c.GenerationBasename, "error", err)
		return ""
	}
	if err := r.verifyCurrentServiceable(context.Background(), c); err != nil {
		r.logger.WarnContext(context.Background(), "memory search: current pointer references unserviceable generation; not using as rollback target",
			"basename", c.GenerationBasename, "error", err)
		return ""
	}
	return c.GenerationBasename
}

// verifyCurrentServiceable confirms that the generation referenced by a
// current pointer can actually be opened by the query path. It runs at
// the top of every runOnce pass before the same-revision no-op short-
// circuits, so a pointer that references a missing, partial, or
// corrupted generation forces a rebuild rather than leaving the
// reconciler permanently idle.
//
// Cheap checks first (any failure returns an error and runOnce falls
// through to rebuild):
//  1. GenerationBasename is a single path component (validateBasename).
//  2. The generation directory exists.
//  3. BUILD_COMPLETE marker is present (build reached the durable end).
//  4. manifest.json is parseable, has matching schema, binding
//     fingerprint, bijection, and a SourceRevision that matches the
//     pointer's claim. A tampered or stale manifest fails here.
//
// Final smoke test: actually open the Bleve index via OpenGeneration
// and close it immediately. This is what codex3's round-8 blocker
// requires: a non-empty index directory does NOT imply a usable index.
// Bleve scorch needs its root metadata file (root store) and at least
// one segment; a torn write that left the directory populated but with
// metadata missing, or an attacker who replaced the contents with
// junk, must fail here so the reconciler rebuilds rather than
// reporting "ready" while Current() would return bleve open failed.
//
// Performance: OpenGeneration is called only on the same-revision
// no-op path, which is the common steady state — every reconcile pass
// where nothing changed pays one open+close. Different-revision
// passes (the actual rebuild path) never reach this branch. The
// cheap checks above reject the common corruption modes (missing dir,
// missing marker, corrupt manifest) before bleve.Open runs.
func (r *Reconciler) verifyCurrentServiceable(ctx context.Context, cur *Current) error {
	if cur == nil {
		return fmt.Errorf("current pointer is nil")
	}
	if err := validateBasename(cur.GenerationBasename); err != nil {
		return fmt.Errorf("basename: %w", err)
	}
	genDir := filepath.Join(r.generationsDir, cur.GenerationBasename)
	info, err := os.Stat(genDir)
	if err != nil {
		return fmt.Errorf("stat generation dir: %w", err)
	}
	if !info.IsDir() {
		return fmt.Errorf("generation path is not a directory: %s", genDir)
	}
	if _, err := os.Stat(filepath.Join(genDir, buildCompleteMarker)); err != nil {
		return fmt.Errorf("build_complete marker: %w", err)
	}
	manifest, err := LoadManifest(ctx, filepath.Join(genDir, manifestFilename), r.opts.Resolver)
	if err != nil {
		return fmt.Errorf("manifest: %w", err)
	}
	if manifest.SourceRevision != cur.SourceRevision {
		return fmt.Errorf("manifest revision %q does not match current pointer %q",
			manifest.SourceRevision, cur.SourceRevision)
	}
	// Final smoke test: borrow the Bleve index through the same registry
	// Current uses, then exercise the reader rather than attempting a
	// second independent Scorch open (which can block on bbolt). External
	// deletion/corruption of the backing store is detected by these reads;
	// a pool created after corruption fails during OpenGeneration itself.
	gen, err := r.pools.BorrowFor(ctx, genDir)
	if err != nil {
		return fmt.Errorf("bleve open smoke: %w", err)
	}
	if err := smokeValidate(ctx, gen.Index(), len(manifest.Chunks)); err != nil {
		_ = gen.Close()
		return fmt.Errorf("bleve query smoke: %w", err)
	}
	if err := gen.Close(); err != nil {
		return fmt.Errorf("bleve close after smoke: %w", err)
	}
	return nil
}

// snapshotChunks derives chunks for every file in the snapshot by
// re-reading source file content through a confined os.Root and
// re-hashing the bytes. The scan-time ContentHash is treated as a hint
// from an earlier pass; this read is the authoritative one. If a file
// has been swapped, grown, shrunk, or replaced with a symlink between
// Scan and this read, the bytes will not match the recorded hash and
// the candidate is rejected with ErrDirtyDuringBuild.
//
// Confinement via os.Root is required because the source clone is
// mutable and watched: an attacker who can race the chunk read with a
// symlink swap could otherwise read content outside the clone. We
// reuse the same readAndHash primitive as Scan so the two passes apply
// identical TOCTOU defenses (identity check, size check, UTF-8 check,
// bound check).
func (r *Reconciler) snapshotChunks(ctx context.Context, snap SourceSnapshot) ([]SearchChunk, error) {
	rootFile, err := os.OpenRoot(r.resolvedRoot)
	if err != nil {
		return nil, fmt.Errorf("open source root: %w", err)
	}
	defer rootFile.Close()
	// Re-stat the root inode after OpenRoot and compare to a pre-open
	// Lstat. A root swap between MkdirAll and OpenRoot would otherwise
	// let us read from a different tree than the one Scan walked.
	preRootStat, err := os.Lstat(r.resolvedRoot)
	if err != nil {
		return nil, fmt.Errorf("lstat source root: %w", err)
	}
	openedRootStat, err := rootFile.Stat(".")
	if err != nil {
		return nil, fmt.Errorf("stat opened root: %w", err)
	}
	if !os.SameFile(preRootStat, openedRootStat) {
		return nil, fmt.Errorf("%w: source root inode changed between lstat and openroot", ErrDirtyDuringBuild)
	}

	// Deterministic order: by source-relative path. chunk IDs are
	// path-derived so the same chunk inventory is produced regardless
	// of map iteration order.
	paths := make([]string, 0, len(snap.Files))
	for p := range snap.Files {
		paths = append(paths, p)
	}
	sort.Strings(paths)

	var chunks []SearchChunk
	for _, p := range paths {
		if err := ctx.Err(); err != nil {
			return nil, err
		}
		sf := snap.Files[p]
		// Lstat the path under the source root (lexical path, not
		// rooted) to obtain the pre-open FileInfo readAndHash compares
		// against. Lstat (not Stat) so a symlink swap is observable
		// rather than followed.
		walkStat, err := os.Lstat(filepath.Join(r.resolvedRoot, sf.Path))
		if err != nil {
			return nil, fmt.Errorf("%w: lstat %s: %v", ErrDirtyDuringBuild, sf.Path, err)
		}
		data, hash, err := readAndHash(rootFile, sf.Path, walkStat, MaxFileBytes)
		if err != nil {
			// Treat any read/hash failure as drift: the file may have
			// grown, been swapped, or turned into a binary between
			// Scan and this read. Re-running the reconcile picks up
			// the new state.
			return nil, fmt.Errorf("%w: read %s: %v", ErrDirtyDuringBuild, sf.Path, err)
		}
		if hash != sf.ContentHash {
			// The bytes on disk no longer match the hash Scan recorded.
			// Either the file mutated between Scan and chunk read, or
			// the snapshot was forged. Either way the candidate built
			// from these bytes would be inconsistent with the
			// published manifest revision; reject and let the next
			// pass pick up the fresh state.
			return nil, fmt.Errorf("%w: hash drift for %s: scan=%s disk=%s",
				ErrDirtyDuringBuild, sf.Path, sf.ContentHash, hash)
		}
		chunks = append(chunks, Chunk(ChunkInput{
			Path:           sf.Path,
			Kind:           sf.Kind,
			Format:         sf.Format,
			Archived:       sf.Archived,
			ContentHash:    sf.ContentHash,
			SourceRevision: snap.Revision,
			Data:           data,
		})...)
	}
	return chunks, nil
}

// gcOlderGenerations is retained as the crash-journal clearing hook. Durable
// lifecycle retention owns all completed-generation deletion; this hook only
// validates journal basenames and delegates to RunRetentionGC. Keeping the old
// "current + previous, delete everything older" policy here would violate the
// 24-hour rollback contract after rapid successive publishes.
func (r *Reconciler) gcOlderGenerations(ctx context.Context, currentBasename, previousBasename string) error {
	if err := validateBasename(currentBasename); err != nil {
		return fmt.Errorf("gc current basename: %w", err)
	}
	if err := validateBasename(previousBasename); err != nil {
		// Previous may legitimately be empty (first publish); a
		// non-empty invalid value is a forged journal and we refuse.
		return fmt.Errorf("gc previous basename: %w", err)
	}
	return r.RunRetentionGC(ctx)
}

// sweepOrphanGenerations drops every generation directory that lacks
// BUILD_COMPLETE and is not referenced by current.json or the
// pending-rollback slot. It runs at recovery time so a crash that left
// a partial candidate on disk — without a durable journal pointing at
// it, or with a journal that no longer protects it — is cleaned up
// deterministically rather than accumulating.
//
// The keep set is {current.json basename, pending.PreviousBasename}.
// The pending.CandidateBasename is deliberately NOT kept: a partial
// candidate in building phase lacks BUILD_COMPLETE by definition and
// must be swept. Publishing/clearing candidates are kept via current.json
// (recoverPublishing re-publishes before this sweep runs, so current.json
// already protects the re-published candidate).
//
// Only strictly-validated basenames are removed. The keep-set basenames
// are validated too: an invalid value in current.json or pending.json is
// treated as "does not protect anything" so a forged pointer cannot be
// used to keep an orphan alive.
func (r *Reconciler) sweepOrphanGenerations(ctx context.Context, pending *Pending) error {
	keep := map[string]bool{}
	if c, err := loadCurrent(r.derivativeRoot); err == nil {
		if validateBasename(c.GenerationBasename) == nil {
			keep[c.GenerationBasename] = true
		}
	}
	if pending != nil {
		if validateBasename(pending.PreviousBasename) == nil {
			keep[pending.PreviousBasename] = true
		}
	}
	entries, err := os.ReadDir(r.generationsDir)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil
		}
		return err
	}
	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		name := e.Name()
		if keep[name] {
			continue
		}
		if err := validateBasename(name); err != nil {
			r.logger.WarnContext(ctx, "memory search: orphan sweep skipping invalid generation name",
				"name", name, "error", err)
			continue
		}
		marker := filepath.Join(r.generationsDir, name, buildCompleteMarker)
		if _, err := os.Stat(marker); err == nil {
			// Has BUILD_COMPLETE: not an orphan from a partial build.
			// Leave it; the regular GC pass reclaims stale completed
			// generations once current + previous no longer reference
			// them.
			continue
		} else if !errors.Is(err, os.ErrNotExist) {
			return fmt.Errorf("stat marker for %s: %w", name, err)
		}
		info, err := e.Info()
		if err != nil {
			continue
		}
		if r.clock().Sub(info.ModTime()) < failedGenerationRetention {
			continue
		}
		if err := r.trashGeneration(name); err != nil {
			return fmt.Errorf("trash orphan generation %s: %w", name, err)
		}
		r.logger.InfoContext(ctx, "memory search: swept orphan generation",
			"name", name)
	}
	return nil
}

// Current returns an opened Generation handle to the current published
// generation, or ErrNotReady if no serviceable generation exists. The
// caller must call Close on the returned Generation when done.
//
// Commit B: the handle now comes from the Reconciler's poolRegistry
// rather than a fresh OpenGeneration per call. The published basename
// from current.json is the linearization point — every Current call
// reads it fresh so a publish is reflected on the next Current even if
// the previously published pool still has active borrowers. The pool
// serializes OpenGeneration per basename via singleflight so concurrent
// Current calls for the same published generation share one Bleve
// handle. Borrowers receive a wrapper with a deep-copied manifest;
// wrapper Close releases the ref and (on last release after retire)
// closes the underlying Bleve handle.
func (r *Reconciler) Current(ctx context.Context) (*Generation, error) {
	r.stateMu.Lock()
	closed := r.closed
	r.stateMu.Unlock()
	if closed {
		return nil, ErrClosed
	}
	genDir, err := CurrentBasenameForOpen(ctx, r.derivativeRoot, r.opts.Binding.Fingerprint())
	if err != nil {
		return nil, err
	}
	return r.pools.BorrowFor(ctx, genDir)
}

// Binding returns the immutable validated binding this Reconciler owns.
// Callers (Search) use it to derive SourceRoot for both ready and degraded
// responses without accepting a request-supplied root. The binding was
// validated against the resolver at NewReconciler time and is the same
// pointer used by every Build / OpenGeneration call, so its ResolvedRoot
// stays inside the authenticated agent work directory.
//
// codex3 round 5 secondary 2: returning the binding (not a string) lets
// Search re-derive absolute path + kind from a validated source instead of
// trusting a SearchSource implementation to return a clean path.
//
// Task 7 commit A: the return is a fresh value-copy of the internal
// binding, NOT a pointer to r.opts.Binding. The caller may mutate the
// returned *Binding freely; Manager's snapshot is unaffected. Returning
// the internal pointer would let a caller mutate the binding out from
// under the Manager's fingerprint key and the cross-binding fence.
func (r *Reconciler) Binding() *Binding {
	if r.opts.Binding == nil {
		return nil
	}
	snapshot := *r.opts.Binding
	return &snapshot
}

// MarkQuerySuccess records the first complete, fresh successful query for the
// generation returned by Current. A query that finishes after another
// generation was published is ignored with ErrQueryGenerationSuperseded.
func (r *Reconciler) MarkQuerySuccess(ctx context.Context, generationBasename string) error {
	return r.lifecycle.markQuerySuccess(ctx, generationBasename)
}

// SearchState returns a non-blocking snapshot for a borrowed generation.
// A queued/in-flight reconcile or a generation superseded after Current()
// was borrowed is refreshing. Search still validates every hit against the
// source tree before returning it.
func (r *Reconciler) SearchState(generationBasename string) SearchState {
	r.sourceMu.RLock()
	trusted := r.sourceTrusted
	r.sourceMu.RUnlock()
	r.stateMu.Lock()
	defer r.stateMu.Unlock()
	if !trusted || r.dirtyEpoch > r.ackEpoch ||
		(r.currentBasename != "" && r.currentBasename != generationBasename) {
		return StateRefreshing
	}
	return StateReady
}

// Close stops the goroutine and releases the lock. The owned context
// is cancelled first; in-flight Scan/Build/Chunk calls observe the
// cancellation and unblock. The goroutine then exits and closes
// `finished`, and only THEN does Close release the cross-process lock.
// There is NO timeout on the wait for `finished`: timing out and
// releasing the lock while the writer is still running would let a
// second Desktop instance start a concurrent writer against the same
// derivative — exactly the hazard the lock exists to prevent. A
// writer that refuses to exit is a bug to fix, not a condition to
// paper over with a timer.
//
// Pending journal and any partial work are left on disk for the next
// Start to recover; the caller may opt to purge them via a separate
// maintenance call.
func (r *Reconciler) Close() error {
	r.closeOnce.Do(func() {
		r.stateMu.Lock()
		r.closed = true
		cancel := r.cancel
		r.stateMu.Unlock()
		if cancel != nil {
			cancel()
		}
		// Wait for the goroutine to exit. No timeout: see comment above.
		// The finished channel is unbuffered and closed by agentteam(); if the
		// goroutine never started (Start failed), it is still open, but
		// so is the goroutine state — nothing to wait for.
		select {
		case <-r.finished:
		default:
			// Goroutine never started. Safe to skip the wait.
		}
		// If the goroutine did start, the non-blocking select above may
		// have missed the close; do a blocking wait only if it actually
		// started, to avoid hanging Close when Start was never called.
		r.stateMu.Lock()
		started := r.started
		r.stateMu.Unlock()
		if started {
			<-r.finished
			close(r.trashStop)
			<-r.trashDone
		}
		// Fence new Current calls and safely drain every pooled reader
		// before releasing the cross-process owner lock. A long query may
		// therefore delay Close; closing its Bleve index underneath it
		// would violate the reader lifetime contract.
		if err := r.pools.Close(); err != nil {
			r.closeErr = errors.Join(r.closeErr, err)
		}
		r.stateMu.Lock()
		if r.lock != nil {
			r.lock.Release()
			r.lock = nil
		}
		r.stateMu.Unlock()
	})
	return r.closeErr
}

// finalizeBindingTransition persists a completed lifecycle fence and abandons
// only this binding's unfinished reconcile journal. The caller must first
// synchronously Close the Reconciler: cancellation can leave pending.json at
// any phase, and deleting it while the old writer is still running would let
// that writer recreate the journal after the fence returned.
//
// Close releases the lifetime owner lock, so this method re-acquires the same
// cross-process lock around BOTH the lifecycle write and the fingerprint-CAS
// journal removal. A newer binding may have won the lock in the gap. In that
// case we fail closed with ErrOwnerLockBusy; if it has already stopped and left
// a new journal/lifecycle sidecar behind, the fingerprint checks below refuse
// to overwrite or remove that newer state.
func (r *Reconciler) finalizeBindingTransition(next BindingLifecycleState) error {
	lock, err := TryAcquireOwnerLock(r.derivativeRoot)
	if err != nil {
		return fmt.Errorf("memory search: finalize binding transition: %w", err)
	}
	defer lock.Release()

	// The lifecycle fence is the durable proof used by recovery to distinguish
	// an intentionally abandoned old-binding journal from unexplained
	// cross-binding state. Persist it before clearing the journal.
	if err := r.lifecycle.setBindingState(next); err != nil {
		return fmt.Errorf("memory search: finalize binding transition lifecycle: %w", err)
	}

	r.namespaceMu.Lock()
	defer r.namespaceMu.Unlock()
	pending, err := loadPending(r.derivativeRoot)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil
		}
		return fmt.Errorf("memory search: finalize binding transition load pending: %w", err)
	}
	expected := r.opts.Binding.Fingerprint()
	if pending.BindingFingerprint != expected {
		return fmt.Errorf(
			"%w: transition for %q found pending journal for %q",
			ErrRecoveryBindingMismatch, expected, pending.BindingFingerprint)
	}
	if err := clearPending(r.derivativeRoot); err != nil {
		return fmt.Errorf("memory search: finalize binding transition clear pending: %w", err)
	}
	return nil
}

// ErrDirtyDuringBuild signals that the source revision drifted between
// the pre-build Scan and the post-build re-Scan, or that a chunk-read
// hash check failed. The caller leaves the pending journal dirty and
// runs another pass.
var ErrDirtyDuringBuild = errors.New("memory search: source drifted during build")

// ErrScanDegraded signals that the bounded Scan returned a degraded
// status (I/O error, root swap, etc.). The reconciler refuses to
// publish a partial snapshot and keeps the previous generation
// serviceable.
var ErrScanDegraded = errors.New("memory search: scan degraded; not publishing")

// ErrScanLimited signals that the bounded Scan hit a file-count or
// total-byte cap and truncated the snapshot. The reconciler refuses to
// publish a partial snapshot.
var ErrScanLimited = errors.New("memory search: scan limited; not publishing")

// ErrClosed signals that Start was called on a Reconciler whose Close
// has already been invoked. The caller must construct a new
// Reconciler.
var ErrClosed = errors.New("memory search: reconciler is closed")

// ErrSourceAccessUnknown indicates that authoritative binding lookup is
// temporarily unavailable. The last trusted generation remains readable, but
// derivative writes are frozen until SetSourceTrusted(true).
var ErrSourceAccessUnknown = errors.New("memory search: source access is unknown")

// Compile-time guard: ensure fs import is exercised in case future
// refactors move readAndHash consumers out of this file.
var _ = fs.ErrNotExist
