// Command loomcycle is the loomcycle sidecar.
//
// Usage:
//
//	loomcycle --config loomcycle.yaml
//
// Build identification: the buildVersion / buildCommit / buildTime vars
// are resolved automatically from Go's VCS stamp (embedded by `go build`
// since 1.18) and from the module's tagged version when present. A
// release script may still override any of them via -ldflags to inject
// explicit values, but the unattached default works for `go build`,
// `go install`, and CI without any wrapper tooling.
package main

import (
	"context"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"log"
	"net"
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"regexp"
	"runtime"
	"runtime/debug"
	"sort"
	"strconv"
	"strings"
	"sync"
	"syscall"
	"time"

	"github.com/denn-gubsky/loomcycle/cmd/loomcycle/embedded"
	"github.com/denn-gubsky/loomcycle/internal/agents"
	a2aapi "github.com/denn-gubsky/loomcycle/internal/api/a2a"
	lchttp "github.com/denn-gubsky/loomcycle/internal/api/http"
	webhookapi "github.com/denn-gubsky/loomcycle/internal/api/webhook"
	"github.com/denn-gubsky/loomcycle/internal/audit"
	"github.com/denn-gubsky/loomcycle/internal/channels"
	"github.com/denn-gubsky/loomcycle/internal/cli"
	"github.com/denn-gubsky/loomcycle/internal/clienttools"
	"github.com/denn-gubsky/loomcycle/internal/concurrency"
	"github.com/denn-gubsky/loomcycle/internal/config"
	"github.com/denn-gubsky/loomcycle/internal/coord"
	"github.com/denn-gubsky/loomcycle/internal/credential"
	"github.com/denn-gubsky/loomcycle/internal/heartbeat"
	"github.com/denn-gubsky/loomcycle/internal/help"
	"github.com/denn-gubsky/loomcycle/internal/hooks"
	"github.com/denn-gubsky/loomcycle/internal/lookup"
	mcpsign "github.com/denn-gubsky/loomcycle/internal/mcp"
	"github.com/denn-gubsky/loomcycle/internal/metrics"
	lcotel "github.com/denn-gubsky/loomcycle/internal/otel"
	"github.com/denn-gubsky/loomcycle/internal/pause"
	"github.com/denn-gubsky/loomcycle/internal/providerbuild"
	"github.com/denn-gubsky/loomcycle/internal/providers"
	// RFC BF P2a — the resolver now builds providers via providers.NewDriver, not
	// each driver's New(), so these driver packages are imported ONLY for their
	// init() self-registration into the RFC BF driver registry. Blank imports keep
	// their factories registered; codejs + anthropic_oauth_dev stay named below
	// (still referenced directly: code-agent validation and the residual OAuth path).
	_ "github.com/denn-gubsky/loomcycle/internal/providers/anthropic"
	anthropic_oauth_dev "github.com/denn-gubsky/loomcycle/internal/providers/anthropic_oauth_dev"
	"github.com/denn-gubsky/loomcycle/internal/providers/codejs"
	_ "github.com/denn-gubsky/loomcycle/internal/providers/deepseek"
	_ "github.com/denn-gubsky/loomcycle/internal/providers/gemini"
	_ "github.com/denn-gubsky/loomcycle/internal/providers/mock"
	_ "github.com/denn-gubsky/loomcycle/internal/providers/ollama"
	_ "github.com/denn-gubsky/loomcycle/internal/providers/openai"
	"github.com/denn-gubsky/loomcycle/internal/providers/streamhttp"
	"github.com/denn-gubsky/loomcycle/internal/search"
	// Deterministic stub embedder for runtime tests; its init() registers the
	// "stub" provider ONLY when LOOMCYCLE_EMBEDDER_STUB=1, so it is invisible
	// to a production binary that never sets the flag.
	_ "github.com/denn-gubsky/loomcycle/internal/providers/stubembedder"
	"github.com/denn-gubsky/loomcycle/internal/resolve"
	"github.com/denn-gubsky/loomcycle/internal/retention"
	"github.com/denn-gubsky/loomcycle/internal/runner"
	"github.com/denn-gubsky/loomcycle/internal/runstate"
	"github.com/denn-gubsky/loomcycle/internal/scheduler"
	"github.com/denn-gubsky/loomcycle/internal/skills"
	"github.com/denn-gubsky/loomcycle/internal/sqlmem"
	"github.com/denn-gubsky/loomcycle/internal/steer"
	"github.com/denn-gubsky/loomcycle/internal/store"
	storepostgres "github.com/denn-gubsky/loomcycle/internal/store/postgres"
	storesqlite "github.com/denn-gubsky/loomcycle/internal/store/sqlite"
	"github.com/denn-gubsky/loomcycle/internal/usage"

	googlegrpc "google.golang.org/grpc"

	loomgrpc "github.com/denn-gubsky/loomcycle/internal/api/grpc"
	"github.com/denn-gubsky/loomcycle/internal/api/grpc/loomcyclepb"
	lcmcp "github.com/denn-gubsky/loomcycle/internal/api/mcp"
	memory "github.com/denn-gubsky/loomcycle/internal/memory"
	"github.com/denn-gubsky/loomcycle/internal/memory/backends/inprocess"
	"github.com/denn-gubsky/loomcycle/internal/memory/embedders"
	"github.com/denn-gubsky/loomcycle/internal/tools"
	toolsa2a "github.com/denn-gubsky/loomcycle/internal/tools/a2a"
	"github.com/denn-gubsky/loomcycle/internal/tools/builtin"
	"github.com/denn-gubsky/loomcycle/internal/tools/localapi"
	"github.com/denn-gubsky/loomcycle/internal/tools/mcp"
	mcphttp "github.com/denn-gubsky/loomcycle/internal/tools/mcp/http"
	mcpstdio "github.com/denn-gubsky/loomcycle/internal/tools/mcp/stdio"
)

// Build identification. Empty defaults are resolved at main()-entry
// from Go's VCS stamp via runtime/debug.ReadBuildInfo() — no external
// tooling required. ldflags overrides still win:
//
//	go build -ldflags "-X main.buildVersion=v0.8.14 \
//	                   -X main.buildCommit=$(git rev-parse --short HEAD) \
//	                   -X main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" ...
//
// Resolution precedence per var: ldflags override → runtime/debug →
// "unknown". A binary built outside a VCS-aware context (e.g. `go run`
// from an unpacked tarball) ends up with "unknown" — visible signal
// that the operator should rebuild from a real checkout.
var (
	buildVersion = ""
	buildCommit  = ""
	buildTime    = ""
)

// resolveBuildInfo reads Go's automatically-embedded VCS stamp and
// returns (version, commit, time). commit gets a "-dirty" suffix when
// the working tree was modified at build time. Empty strings mean the
// corresponding info wasn't available (binary built without VCS — e.g.
// `go test` by default does not embed VCS info, but `go build` and
// `go install` do).
// channelDebugEnabled reports whether the operator opted into the
// v0.12.7 channel publish/subscribe diagnostic log via the
// LOOMCYCLE_CHANNEL_DEBUG=1 env var. Read here (not at store package
// init time) so the env-var coupling stays in main.go where all the
// other env-var → Config bridging lives. See
// internal/tools/builtin/channel.go and
// internal/store/postgres/postgres.go for what gets logged.
func channelDebugEnabled() bool {
	return os.Getenv("LOOMCYCLE_CHANNEL_DEBUG") == "1"
}

// backfillAgentDefSignFn computes the v0.9.x content_sha256 for an
// existing agent_defs row whose hash column is NULL/empty. The
// Definition JSONB is the mergedDef shape; agents.FromOverlay
// deserializes the content-bearing subset, then we layer the row's
// Name in (the agent name lives on the row column, not in JSONB).
func backfillAgentDefSignFn(name string, def []byte) (string, error) {
	content, err := agents.FromOverlay(def)
	if err != nil {
		return "", fmt.Errorf("decode agent_defs.definition: %w", err)
	}
	content.Name = name
	return agents.Sign(content), nil
}

// backfillSkillDefSignFn is the mirror against skill_defs rows.
func backfillSkillDefSignFn(name string, def []byte) (string, error) {
	content, err := skills.FromOverlay(def)
	if err != nil {
		return "", fmt.Errorf("decode skill_defs.definition: %w", err)
	}
	content.Name = name
	return skills.Sign(content), nil
}

// backfillMCPServerDefSignFn — v0.9.x mirror against mcp_server_defs rows.
func backfillMCPServerDefSignFn(name string, def []byte) (string, error) {
	content, err := mcpsign.FromOverlay(def)
	if err != nil {
		return "", fmt.Errorf("decode mcp_server_defs.definition: %w", err)
	}
	content.Name = name
	return mcpsign.Sign(content), nil
}

func resolveBuildInfo() (version, commit, builtAt string) {
	info, ok := debug.ReadBuildInfo()
	if !ok {
		return "", "", ""
	}
	var rev string
	var dirty bool
	for _, s := range info.Settings {
		switch s.Key {
		case "vcs.revision":
			rev = s.Value
		case "vcs.time":
			builtAt = s.Value
		case "vcs.modified":
			dirty = s.Value == "true"
		}
	}
	return formatBuildInfo(info.Main.Version, rev, builtAt, dirty)
}

// formatBuildInfo normalises the raw VCS-stamp fields into the display
// shape used by --version and startup logs. Split out from
// resolveBuildInfo so it can be exhaustively unit-tested without
// depending on whether the test binary itself had VCS info embedded.
func formatBuildInfo(mainVersion, rev, builtAt string, dirty bool) (version, commit, ts string) {
	version = mainVersion
	if version == "(devel)" || version == "" {
		// `go build` from a local checkout reports "(devel)"; surface a
		// shorter form so operators can tell a release binary from a
		// developer build at a glance.
		version = "devel"
	}
	// Go's module proxy synthesises a "pseudo-version" for a commit
	// that has no semver tag yet. Shape:
	//   vX.Y.Z-0.YYYYMMDDHHMMSS-abcdef012345
	// (the "0." form means "before any vX.Y.Z tag"). Operators want
	// to see the meaningful base ("v0.8.21"), not the 40-char
	// timestamped suffix that's redundant with `commit` + `built`
	// fields. Strip it. Real prerelease tags like "v0.8.14-rc1" are
	// left alone — the regex only matches the precise pseudo shape.
	version = stripPseudoVersionSuffix(version)
	if len(rev) > 12 {
		rev = rev[:12]
	}
	if dirty && rev != "" {
		rev += "-dirty"
	}
	return version, rev, builtAt
}

// pseudoVersionRE matches Go's auto-synthesised pseudo-versions:
//
//	vMAJOR.MINOR.PATCH-0.YYYYMMDDHHMMSS-<12+ hex>
//
// (Go also emits a leading "0." for the pre-tag case; the v2-style
// post-tag pseudo-version is "<base>-<n>.YYYYMMDDHHMMSS-…" but our
// repo is sub-v2 so the 0.-prefixed form is the only one we see.)
// `(\+\w+)?` tolerates Go's module-level "+dirty" suffix that gets
// appended to the pseudo-version when the working tree has uncommitted
// changes — the version field still reports a clean semver, the
// dirty signal is preserved on the separate commit field.
var pseudoVersionRE = regexp.MustCompile(`^(v\d+\.\d+\.\d+)-0\.\d{14}-[0-9a-f]+(?:\+\w+)?$`)

func stripPseudoVersionSuffix(v string) string {
	if m := pseudoVersionRE.FindStringSubmatch(v); m != nil {
		return m[1]
	}
	return v
}

// printVersion writes the one-line build identifier shared by the `version`
// subcommand and the --version flag. buildVersion/buildCommit/buildTime are
// resolved in main() before either caller runs.
func printVersion() {
	fmt.Printf("loomcycle version=%s commit=%s built=%s go=%s\n",
		buildVersion, buildCommit, buildTime, runtime.Version())
}

// mcpProxyHeaderTimeout reads LOOMCYCLE_MCP_UPSTREAM_HEADER_TIMEOUT_MS (the
// thin-client's time-to-response-headers bound; a stalled upstream must not
// hang a frame forever, and Claude Code's tool-idle timeout doesn't cover stdio
// servers). Unset / invalid → 0, letting NewProxyClient apply its default.
func mcpProxyHeaderTimeout() time.Duration {
	v := os.Getenv("LOOMCYCLE_MCP_UPSTREAM_HEADER_TIMEOUT_MS")
	if v == "" {
		return 0
	}
	n, err := strconv.Atoi(v)
	if err != nil || n < 0 {
		return 0
	}
	return time.Duration(n) * time.Millisecond
}

// mcpProxyReconnectDelays reads LOOMCYCLE_MCP_UPSTREAM_RECONNECT_ATTEMPTS and
// builds the thin-client's transport-error backoff schedule: an immediate first
// retry, then geometric waits (500ms doubling) capped at 4s. Unset → nil, so
// NewProxyClient applies its default schedule; 0 → an empty schedule (retry
// disabled — one attempt only).
func mcpProxyReconnectDelays() []time.Duration {
	v := os.Getenv("LOOMCYCLE_MCP_UPSTREAM_RECONNECT_ATTEMPTS")
	if v == "" {
		return nil
	}
	n, err := strconv.Atoi(v)
	if err != nil || n < 0 {
		return nil
	}
	delays := make([]time.Duration, n)
	for i := 1; i < n; i++ {
		d := 500 * time.Millisecond * (1 << (i - 1))
		if d > 4*time.Second {
			d = 4 * time.Second
		}
		delays[i] = d
	}
	return delays
}

// selectPresetNames resolves the RFC AQ embedded-preset selection: --preset
// flags, if any, override LOOMCYCLE_PRESETS (comma-separated, ordered). Empty /
// unset → no presets (opt-in default — exactly today's behaviour). Order is
// preserved; it becomes the base layer order of the config stack.
func selectPresetNames(flags []string, env string) []string {
	if len(flags) > 0 {
		return flags
	}
	var out []string
	for _, n := range strings.Split(env, ",") {
		if n = strings.TrimSpace(n); n != "" {
			out = append(out, n)
		}
	}
	return out
}

func main() {
	// Resolve build identifiers FIRST so subcommands (validate / agents
	// / health / migrate) and --version see the same auto-resolved
	// values. ldflags overrides win — release scripts that explicitly
	// set buildVersion/buildCommit/buildTime get exactly those.
	autoVersion, autoCommit, autoTime := resolveBuildInfo()
	if buildVersion == "" {
		buildVersion = autoVersion
	}
	if buildCommit == "" {
		buildCommit = autoCommit
	}
	if buildTime == "" {
		buildTime = autoTime
	}
	if buildVersion == "" {
		buildVersion = "unknown"
	}
	if buildCommit == "" {
		buildCommit = "unknown"
	}
	if buildTime == "" {
		buildTime = "unknown"
	}

	// Subcommand dispatch BEFORE flag parsing — let `loomcycle
	// validate ...` flow into the CLI surface without colliding with
	// the server's own --config flag.
	//
	// First non-flag arg is the subcommand keyword. If it's one of
	// the known subcommands, hand off to internal/cli and exit.
	// Otherwise fall through to the server entry point (preserves
	// backwards compat: `loomcycle --config foo.yaml` still works).
	//
	// The `mcp` subcommand is a special case (v0.8.15+): it starts
	// the SAME server setup as the default flow, plus a stdio MCP
	// listener reading os.Stdin / writing os.Stdout. CRITICAL: in
	// mcp mode, no code may write to os.Stdout outside the MCP loop
	// — stdout is the JSON-RPC wire. The stdlib `log` package
	// defaults to os.Stderr (so log lines are safe); any future
	// `fmt.Println` / direct os.Stdout writes elsewhere in the
	// binary would corrupt the protocol.
	mcpMode := false
	if len(os.Args) > 1 {
		switch os.Args[1] {
		case "validate":
			os.Exit(cli.RunValidate(os.Args[2:], os.Stdout, os.Stderr))
		case "agents":
			os.Exit(cli.RunAgents(os.Args[2:], os.Stdout, os.Stderr))
		case "anthropic":
			// v0.11.9 — OAuth-dev subcommands (login / status / logout).
			// Gated by LOOMCYCLE_ANTHROPIC_OAUTH_DEV_ENABLED=1 inside
			// the subcommand handler; without the env var, the
			// subcommand prints a clear error pointing at docs.
			os.Exit(cli.RunAnthropic(os.Args[2:], os.Stdout, os.Stderr))
		case "health":
			os.Exit(cli.RunHealth(os.Args[2:], os.Stdout, os.Stderr))
		case "migrate":
			os.Exit(cli.RunMigrate(os.Args[2:], os.Stdout, os.Stderr))
		case "operator-token":
			// RFC L — mint / rotate / retire / show / list auth tokens
			// against the running instance's admin endpoint.
			os.Exit(cli.RunOperatorToken(os.Args[2:], os.Stdout, os.Stderr))
		// v0.8.17 runtime admin subcommands. Each is a thin HTTP
		// client to the corresponding /v1/_pause / _resume / _state /
		// _snapshots endpoint on the running instance addressed by
		// $LOOMCYCLE_BASE_URL.
		case "pause":
			os.Exit(cli.RunPause(os.Args[2:], os.Stdout, os.Stderr))
		case "resume":
			os.Exit(cli.RunResume(os.Args[2:], os.Stdout, os.Stderr))
		case "state":
			os.Exit(cli.RunState(os.Args[2:], os.Stdout, os.Stderr))
		case "snapshot":
			os.Exit(cli.RunSnapshot(os.Args[2:], os.Stdout, os.Stderr))
		case "snapshots":
			// `snapshots <verb>` dispatch — list / export / delete.
			if len(os.Args) < 3 {
				fmt.Fprintln(os.Stderr, "loomcycle: error: usage: loomcycle snapshots <list|export|delete> ...")
				os.Exit(2)
			}
			switch os.Args[2] {
			case "list":
				os.Exit(cli.RunSnapshotsList(os.Args[3:], os.Stdout, os.Stderr))
			case "export":
				os.Exit(cli.RunSnapshotsExport(os.Args[3:], os.Stdout, os.Stderr))
			case "delete":
				os.Exit(cli.RunSnapshotsDelete(os.Args[3:], os.Stdout, os.Stderr))
			default:
				fmt.Fprintf(os.Stderr, "loomcycle: error: unknown snapshots subcommand %q (want: list / export / delete)\n", os.Args[2])
				os.Exit(2)
			}
		case "restore":
			os.Exit(cli.RunRestore(os.Args[2:], os.Stdout, os.Stderr))
		case "hash":
			os.Exit(cli.RunHash(os.Args[2:], os.Stdout, os.Stderr))
		case "mcp":
			// v0.12.x: `loomcycle mcp install` prints copy-paste config
			// snippets for Claude Code / Claude Desktop. Must be checked
			// BEFORE the mcpMode strip below — otherwise "install" leaks
			// into the server's flag set and is mis-parsed as a positional.
			if len(os.Args) >= 3 && os.Args[2] == "install" {
				os.Exit(cli.RunMCPInstall(os.Args[3:], os.Stdout, os.Stderr))
			}
			mcpMode = true
			// Strip "mcp" from os.Args so flag.Parse() below sees
			// the remaining flags (--config etc.) at index 1+.
			os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
		case "init":
			os.Exit(cli.RunInit(os.Args[2:], os.Stdout, os.Stderr))
		case "doctor":
			os.Exit(cli.RunDoctor(os.Args[2:], os.Stdout, os.Stderr))
		case "presets":
			// RFC AQ — list the embedded presets/bundles, or `presets show <name>`
			// to print one's YAML. Read-only introspection of the embedded base.
			os.Exit(cli.RunPresets(os.Args[2:], os.Stdout, os.Stderr))
		case "env-template":
			// RFC AQ — print the embedded .env.insecure.example (non-secret env
			// catalogue) so it's reachable without a source checkout.
			os.Exit(cli.RunEnvTemplate(os.Args[2:], os.Stdout, os.Stderr))
		case "truenas-questions":
			// RFC AR Phase 2 — generate the catalog form's env-options block from
			// the embedded env catalogue (keeps the form in lockstep, no drift).
			os.Exit(cli.RunTrueNASQuestions(os.Args[2:], os.Stdout, os.Stderr))
		case "mcp-registry":
			// v1.x RFC C1: curated MCP server recipe library.
			// Seven verbs (list / show / append-to-config / add /
			// remove / enable / disable) dispatched inside the Run
			// helper. Reads bundled recipes + LOOMCYCLE_MCP_RECIPES_ROOT
			// overlay; emits yaml into the operator's loomcycle.yaml
			// via append-to-config.
			os.Exit(cli.RunMCPRegistry(os.Args[2:], os.Stdout, os.Stderr))
		case "import":
			// v1.x RFC C2: Claude Code repo ingestion. Currently the
			// only subverb is `claude-code`; future subverbs cover
			// other source shapes (plain .mcp.json directories, etc.).
			// Reads a .claude/ tree and emits agents:/mcp_servers:/skill
			// fragments into the operator's loomcycle.yaml.
			os.Exit(cli.RunImport(os.Args[2:], os.Stdout, os.Stderr))
		case "memory-eval":
			// v1.x RFC I (MR-5): scores the memory ranker/dedup against a
			// dataset of (query, expected_recall) tuples — precision@k,
			// recall@k, duplication_rate. The gating tool for ranker PRs.
			os.Exit(cli.RunMemoryEval(os.Args[2:], os.Stdout, os.Stderr))
		case "memory-eval-live":
			// RFC BL P4a — the memory extraction eval against a REAL model. Scores
			// the SHIPPED extractor prompt's judgement per ability (extraction,
			// property, temporal, update, abstention) and gates on updates +
			// abstention. Not hermetic: it needs a provider and spends tokens.
			os.Exit(cli.RunMemoryEvalLive(os.Args[2:], os.Stdout, os.Stderr))
		case "memory-calibrate":
			// Measures the consolidation similarity bands against the
			// operator's OWN embedder. Cosine scale is a property of the
			// embedding model, so a shipped default is right for at most one
			// model — and wrong SILENTLY, in both directions. Exits non-zero
			// when no threshold can separate duplicates from related facts.
			os.Exit(cli.RunMemoryCalibrate(os.Args[2:], os.Stdout, os.Stderr))
		case "help", "-h", "--help":
			cli.PrintHelp(os.Stdout, lcmcp.MetaToolCount())
			return
		case "version", "-v":
			// Real `version` subcommand (mirrors --version). Prints + exits;
			// it must NEVER fall through to booting the runtime.
			printVersion()
			return
		default:
			// A non-flag first arg that isn't a known subcommand is a
			// mistake — e.g. `loomcycle version` historically fell through
			// here and BOOTED the runtime, silently binding the port. Reject
			// it loudly. Flags (--config / --version / --upstream) are NOT
			// subcommands: they start with "-" and fall through to flag
			// parsing below (preserving `loomcycle --config foo.yaml`).
			if !strings.HasPrefix(os.Args[1], "-") {
				fmt.Fprintf(os.Stderr, "loomcycle: error: unknown subcommand %q\n", os.Args[1])
				fmt.Fprintln(os.Stderr, "run `loomcycle help` for subcommands, or `loomcycle --config <file>` to start the runtime.")
				os.Exit(2)
			}
		}
	}

	// --config is repeatable (RFC AN config layering): each flag adds a layer,
	// merged left→right with last-wins. No flag → the XDG default below.
	var cfgPaths []string
	flag.Func("config", "path to config YAML (repeatable; layers deep-merge left→right, last wins)", func(v string) error {
		cfgPaths = append(cfgPaths, v)
		return nil
	})
	// --preset is the repeatable flag equivalent of LOOMCYCLE_PRESETS (RFC AQ):
	// each names an embedded preset/bundle layered as the BASE of the stack, in
	// order. Flags, if any, override the env selection.
	var presetFlags []string
	flag.Func("preset", "embedded preset/bundle to layer as a base (repeatable; e.g. base, document-agent)", func(v string) error {
		presetFlags = append(presetFlags, v)
		return nil
	})
	showVersion := flag.Bool("version", false, "print build identifier and exit")
	// RFC R thin-client mode: `loomcycle mcp --upstream <url>` runs as a
	// stdio↔/v1/_mcp proxy to an authoritative runtime, with NO local
	// runtime. The single-runtime invariant — a second loomcycle process
	// is a control client, never a second runtime. This replaced the old
	// `--no-http` flag (removed in v0.23.0), which "muted the listener"
	// while still booting a full second runtime — the anti-pattern that
	// caused the cross-process interruption hang (F15) and rogue-runtime
	// wedge (F16). To add an MCP surface next to a runtime, use --upstream.
	upstream := flag.String("upstream", "", "(`mcp` mode) run as a thin client proxying to this runtime's /v1/_mcp instead of booting a local runtime")
	flag.Parse()

	if *showVersion {
		printVersion()
		return
	}

	// Identify ourselves first thing so an operator running a stale
	// binary spots it immediately — before any "but my code says X"
	// debugging spiral. Critical when development cycle is "git pull
	// && restart" without a rebuild step in between.
	log.Printf("loomcycle build: version=%s commit=%s time=%s", buildVersion, buildCommit, buildTime)

	// RFC R thin-client mode runs BEFORE any runtime boot — no config
	// load, no providers, no store, no listener, no second runtime. The
	// proxy needs only the upstream URL + bearer + stdio.
	if mcpMode && *upstream != "" {
		proxyCtx, stopProxy := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
		defer stopProxy()
		pc := lcmcp.NewProxyClient(lcmcp.ProxyConfig{
			Upstream: *upstream,
			Token:    os.Getenv("LOOMCYCLE_MCP_UPSTREAM_TOKEN"),
			Logf:     log.Printf, // stderr; never stdout (stdout is the JSON-RPC wire)
			// Self-recovery knobs (defaults applied by NewProxyClient when unset).
			// The thin client boots with NO config load, so these are read straight
			// from the environment.
			ResponseHeaderTimeout: mcpProxyHeaderTimeout(),
			ReconnectDelays:       mcpProxyReconnectDelays(),
		})
		log.Printf("mcp: thin-client mode → upstream %s (no local runtime)", *upstream)
		err := pc.Serve(proxyCtx, os.Stdin, os.Stdout)
		switch {
		case err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded):
			// Clean stop: stdin EOF (client disconnect) or a received
			// signal. Exit 0 so a supervisor doesn't treat a graceful
			// stop as a crash and loop-restart.
			log.Printf("mcp: proxy stopped")
		default:
			log.Printf("mcp: proxy ended: %v", err)
			os.Exit(1)
		}
		return
	}

	// RFC AQ — resolve the embedded-preset selection (base of the config stack).
	// --preset flags, if any, override the LOOMCYCLE_PRESETS env list; OPT-IN by
	// default (neither set → no presets → exactly today's behaviour). A bad name
	// is fatal (typo protection) and lists the available units.
	presetNames := selectPresetNames(presetFlags, os.Getenv("LOOMCYCLE_PRESETS"))
	var presetLayers []config.Layer
	if len(presetNames) > 0 {
		units, err := embedded.ResolveUnits(presetNames)
		if err != nil {
			fmt.Fprintf(os.Stderr, "loomcycle: %v\n", err)
			os.Exit(1)
		}
		for _, u := range units {
			presetLayers = append(presetLayers, config.Layer{Name: u.Name, Data: u.Data})
		}
		log.Printf("config: layering %d embedded preset(s)/bundle(s) as base: %s", len(presetNames), strings.Join(presetNames, ", "))
	}

	// Assemble the operator's config-FILE layers (RFC AN/AQ). The precedence
	// chain, base → top, last wins:
	//   embedded presets → LOOMCYCLE_CONFIG_DIR/*.yaml → LOOMCYCLE_CONFIG_FILES → --config
	// With none of these, fall back to the lone XDG-discovered default (today's
	// behavior) — unless embedded presets are active, in which case a presets-only
	// stack (no operator file) is allowed (RFC AQ bare-start).

	// LOOMCYCLE_CONFIG_DIR (RFC AQ §4) — a directory whose *.yaml/*.yml files layer
	// as a group, lexically, between the presets and CONFIG_FILES. For a mounted
	// overlay dir or an image-baked drop-in set. Unset → skipped.
	var configDirFiles []string
	if dir := strings.TrimSpace(os.Getenv("LOOMCYCLE_CONFIG_DIR")); dir != "" {
		files, err := configDirLayers(dir)
		if err != nil {
			fmt.Fprintf(os.Stderr, "loomcycle: LOOMCYCLE_CONFIG_DIR: %v\n", err)
			os.Exit(1)
		}
		configDirFiles = files
		if len(files) > 0 {
			log.Printf("config: layering %d file(s) from LOOMCYCLE_CONFIG_DIR=%s (lexical order)", len(files), dir)
		}
	}

	var cfgFiles []string
	for _, f := range strings.Split(os.Getenv("LOOMCYCLE_CONFIG_FILES"), ":") {
		if f = strings.TrimSpace(f); f != "" {
			cfgFiles = append(cfgFiles, f)
		}
	}
	cfgFiles = append(cfgFiles, cfgPaths...)

	if len(cfgFiles) == 0 && len(configDirFiles) == 0 {
		// No explicit operator config (dir or files) → v0.11.1 XDG auto-discovery.
		// The search list is the same one `loomcycle doctor` uses (lockstep).
		resolvedCfg, found := resolveConfigPath("loomcycle.yaml")
		switch {
		case found:
			cfgFiles = []string{resolvedCfg}
		case len(presetLayers) > 0:
			// Presets-only: the embedded base IS the config (RFC AQ §2.2).
			log.Printf("config: no operator config file — running from embedded presets only")
		default:
			fmt.Fprintln(os.Stderr, "loomcycle: no config found at any of:")
			for _, p := range configAutoDiscoveryPaths() {
				fmt.Fprintf(os.Stderr, "    %s\n", p)
			}
			fmt.Fprintln(os.Stderr)
			fmt.Fprintln(os.Stderr, "Run `loomcycle init` to create one, pass --config <path>, set LOOMCYCLE_CONFIG_DIR, or select an embedded base with LOOMCYCLE_PRESETS=base.")
			os.Exit(1)
		}
	} else {
		// Explicit --config / CONFIG_FILES layers must each exist (no per-file XDG
		// fallback — explicit means literal). The CONFIG_DIR files were already
		// validated by configDirLayers. Fail fast with the offending path.
		for _, f := range cfgFiles {
			if _, err := os.Stat(f); err != nil {
				fmt.Fprintf(os.Stderr, "loomcycle: config file not found: %s\n", f)
				os.Exit(1)
			}
		}
	}

	// The ordered disk-file layers: CONFIG_DIR first, then CONFIG_FILES/--config.
	diskFiles := append(append([]string{}, configDirFiles...), cfgFiles...)
	if len(diskFiles) > 1 {
		log.Printf("config: layering %d files (last wins): %s", len(diskFiles), strings.Join(diskFiles, " ◁ "))
	}
	// Auto-load <configdir>/auth.env (companion to `loomcycle init
	// --with-token`) BEFORE config.Load reads os.Getenv, so a persisted
	// LOOMCYCLE_AUTH_TOKEN is in scope without a shell-rc edit. Set-if-unset
	// (real env wins), so an explicit shell export still overrides it. Keyed on
	// the LAST (authoritative) disk-file layer — that's where an init-written
	// auth.env lives. A presets-only stack has no file, so there's nothing to load.
	if len(diskFiles) > 0 {
		if authEnvPath, n, err := cli.LoadAuthEnv(diskFiles[len(diskFiles)-1]); err != nil {
			log.Printf("auth.env: %v (continuing without it)", err)
		} else if n > 0 {
			log.Printf("auth.env: loaded %d var(s) from %s (a shell export overrides them)", n, authEnvPath)
		}
	}
	// Build the full ordered layer list. RFC BF P2a: the embedded default-providers
	// layer is the UNCONDITIONAL base (before opt-in presets) so a config with no
	// `providers:` block resolves providers identically to pre-P2a; operator
	// `providers:` entries deep-merge over it. LOOMCYCLE_NO_DEFAULT_PROVIDERS=1 drops
	// it (then only explicitly-declared providers exist). It is kept OUT of
	// presetLayers so the "no config found" error above still fires on a bare run
	// (a lone default-providers layer is not, on its own, a usable config). Order,
	// base→top (last wins):
	//   providers.default → opt-in presets → CONFIG_DIR → CONFIG_FILES/--config
	layers := make([]config.Layer, 0, 1+len(presetLayers)+len(diskFiles))
	if os.Getenv("LOOMCYCLE_NO_DEFAULT_PROVIDERS") != "1" {
		layers = append(layers, config.Layer{Name: "providers.default", Data: embedded.DefaultProviders()})
	}
	layers = append(layers, presetLayers...)
	for _, f := range diskFiles {
		layers = append(layers, config.Layer{Name: f})
	}
	cfg, err := config.LoadLayers(layers...)
	if err != nil {
		log.Fatalf("config: %v", err)
	}
	// Non-fatal config advisories (e.g. an agent with Memory in tools
	// but no memory_scopes — the tool would default-deny every call, F21).
	// Surfaced once at boot so a silent-ish partial misconfig is visible.
	for _, warn := range cfg.Warnings {
		log.Printf("config: WARNING: %s", warn)
	}

	// v0.10.0 OpenTelemetry tracer bootstrap. No-op when
	// LOOMCYCLE_OTEL_EXPORTER_OTLP_ENDPOINT is unset, so zero runtime
	// cost on deployments that haven't opted in. When set, every
	// otel.Tracer(...).Start(ctx, ...) call across the codebase emits
	// to the configured OTLP/HTTP collector.
	otelShutdown, otelErr := lcotel.Init(lcotel.Config{
		Endpoint:       cfg.Env.OTELExporterEndpoint,
		Headers:        cfg.Env.OTELExporterHeaders,
		ServiceName:    cfg.Env.OTELServiceName,
		ServiceVersion: buildVersion,
		SamplerRatio:   cfg.Env.OTELTracesSamplerRatio,
	})
	if otelErr != nil {
		log.Fatalf("otel: %v", otelErr)
	}
	if cfg.Env.OTELExporterEndpoint != "" {
		log.Printf("otel: tracer enabled — endpoint=%s service=%s sampler=traceidratio@%g",
			cfg.Env.OTELExporterEndpoint, cfg.Env.OTELServiceName, cfg.Env.OTELTracesSamplerRatio)
	} else {
		log.Printf("otel: disabled (set LOOMCYCLE_OTEL_EXPORTER_OTLP_ENDPOINT to enable)")
	}

	pr, err := newProviderResolver(cfg)
	if err != nil {
		log.Fatalf("provider resolver: %v", err)
	}
	validateCodeAgents(cfg, pr)
	sem := concurrency.New(
		cfg.Concurrency.MaxConcurrentRuns,
		cfg.Concurrency.MaxQueueDepth,
		cfg.Concurrency.QueueTimeout(),
	).WithPerUserCap(cfg.Concurrency.MaxConcurrentRunsPerUser)
	if cfg.Concurrency.MaxConcurrentRunsPerUser > 0 {
		log.Printf("concurrency: per-user cap enabled — max_concurrent_runs_per_user=%d (global cap=%d)",
			cfg.Concurrency.MaxConcurrentRunsPerUser, cfg.Concurrency.MaxConcurrentRuns)
	}
	// All built-in tools are SANDBOXED: each refuses every call until the
	// operator explicitly enables it via env. We register every tool and
	// log a note for any that's still disabled — that way the model sees
	// a clear "tool refused" error instead of a confusing "unknown tool",
	// and operators see at startup which tools they've configured.
	// Strip loopback aliases (localhost, 127.0.0.1, etc.) from the
	// operator's static allowlist. Belt-and-braces — the IP-level
	// guard at dial time also rejects loopback, but stripping here
	// means loopback never appears in the tool's effective list and
	// operators don't get fooled by seeing "localhost" listed.
	//
	// PrivateHostAllowlist acts as the exemption: if the operator
	// has explicitly opted in to a loopback host via that env var,
	// it survives the strip on the main allowlist too. This is what
	// lets a single "localhost" entry mean "agents may reach this
	// loopback host AND the IP-private check is lifted for it".
	staticHosts := builtin.StripLocalhostAliases(
		cfg.Env.HTTPHostAllowlist,
		cfg.Env.HTTPPrivateHostAllowlist,
	)
	httpTool := &builtin.HTTP{
		HostAllowlist:        staticHosts,
		PrivateHostAllowlist: cfg.Env.HTTPPrivateHostAllowlist,
	}
	// Skill tool reuses the same name→body registry that the static
	// bundling path (Approach A in internal/config) uses. Loading once
	// at boot keeps the in-memory map authoritative — SIGHUP-style
	// hot-reload of skills is a future enhancement.
	skillSet, err := skills.LoadSet(cfg.Env.SkillsRoot)
	if err != nil {
		log.Fatalf("skills: %v", err)
	}
	if cfg.Env.SkillsRoot != "" {
		log.Printf("skills: loaded %d from %s", len(skillSet.Names()), cfg.Env.SkillsRoot)
	}
	// RFC BA: fold inline `skills:` yaml defs into the runtime set so they are
	// invokable + listable on demand via the Skill tool. Before RFC BA the
	// config-load resolveSkills bundled their bodies into prompts (and merged
	// them into a throwaway set); now that bundling is gone, they must live in
	// the runtime set the Skill/SkillDef tools + Library share. Inline wins on
	// a name collision with a SkillsRoot skill (config is authoritative).
	for name, spec := range cfg.Skills {
		skillSet.Add(&skills.Skill{Name: name, Description: spec.Description, Tools: spec.Tools, Body: spec.Body})
	}
	// v0.8.8 help topics — bundled defaults overlaid with operator
	// .md files from LOOMCYCLE_HELP_ROOT. Always non-nil after this
	// load; bundled-only deployments get the default topic set.
	helpSet, err := help.LoadSet(cfg.Env.HelpRoot)
	if err != nil {
		log.Fatalf("help: %v", err)
	}
	if cfg.Env.HelpRoot != "" {
		log.Printf("help: loaded %d topics (filesystem overlay at %s)", len(helpSet.Names()), cfg.Env.HelpRoot)
	} else {
		log.Printf("help: loaded %d bundled topics (no LOOMCYCLE_HELP_ROOT overlay)", len(helpSet.Names()))
	}
	if cfg.Env.AgentsRoot != "" {
		// Agents-from-MD discovery happened inside config.Load (must run
		// before resolveSystemPromptFiles so the merged map flows through
		// the existing pipeline). Log the count post-merge so operators
		// see the final cfg.Agents size, which may include yaml-only
		// entries on top of the discovered ones.
		log.Printf("agents: discovered from %s — total in cfg.Agents (after yaml merge): %d", cfg.Env.AgentsRoot, len(cfg.Agents))
	}
	if len(cfg.UserTiers) > 0 {
		// v0.8.2 — log configured user_tier policies at boot so
		// operators see what's available. "default" surfaces first
		// when present (it's the required entry per validation); the
		// rest sort lexicographically.
		names := make([]string, 0, len(cfg.UserTiers))
		for n := range cfg.UserTiers {
			if n != "default" {
				names = append(names, n)
			}
		}
		sort.Strings(names)
		if _, hasDefault := cfg.UserTiers["default"]; hasDefault {
			names = append([]string{"default"}, names...)
		}
		log.Printf("user_tiers: configured %d — %s", len(names), strings.Join(names, " / "))
	}

	// RFC BB: build the search-provider registry + resolver + operator host-key
	// map for the WebSearch fallback circuit. Nil (no providers configured, and
	// no BRAVE_API_KEY back-compat default) → WebSearch refuses "not configured".
	searchRegistry, searchResolver, searchHostKeys := buildSearchProviders(cfg)

	// Bashbox is a named var so its CredResolve can be late-bound after the
	// credential engine is constructed below (RFC BD per-tenant repo token).
	bashboxTool := &builtin.Bashbox{
		Enabled:              cfg.Env.BashboxEnabled,
		FallbackCommands:     cfg.Env.BashboxFallbackCommands,
		FallbackAllowedEnv:   cfg.Env.BashboxFallbackAllowedEnv,
		FallbackAllowedCreds: cfg.Env.BashboxFallbackAllowedCreds,
	}

	// Bash is likewise a named var so its CredResolve can be late-bound after
	// the credential engine — it injects per-tenant creds into the raw child env.
	bashTool := &builtin.Bash{
		Enabled:      cfg.Env.BashEnabled,
		AllowedCreds: cfg.Env.BashAllowedCreds,
	}

	allTools := []tools.Tool{
		// The file/exec tools resolve their sandbox root per-call from the
		// run's VolumePolicy (RFC AH); an agent bound to no volume is refused
		// (sandbox-by-default — the legacy READ_ROOT/WRITE_ROOT/BASH_CWD jail
		// was retired in Phase 3).
		&builtin.Read{},
		&builtin.Write{},
		&builtin.Edit{},
		&builtin.Grep{},
		&builtin.Glob{},
		&builtin.NotebookEdit{},
		httpTool,
		&builtin.WebFetch{HTTP: httpTool},
		&builtin.WebSearch{Registry: searchRegistry, Resolver: searchResolver, HostKeys: searchHostKeys, ShowProvenance: cfg.Env.WebSearchProvenance},
		// Bash — constructed above so CredResolve can be late-bound after the
		// credential engine exists (per-tenant cred injection into the child env).
		bashTool,
		// Bashbox — a TRUE in-process sandbox (gbash): no OS process, paths
		// rooted at the volume, no network, and read-only volumes are honored
		// (writes hit an in-RAM overlay). Opt-in like Bash. Constructed above so
		// CredResolve can be late-bound after the credential engine exists.
		bashboxTool,
		// SkillTool's Store is late-bound below (so DB-active SkillDef
		// rows override the static Set body). Nil-Store before
		// late-binding falls back to the static Set; the assignment
		// after storeIface is constructed switches the resolution
		// order to DB-first.
	}
	skillTool := &builtin.SkillTool{Set: skillSet}
	allTools = append(allTools, skillTool)
	// Memory tool — wired post-store so it can grab the live Store
	// reference. Registered unconditionally; access is gated per-agent
	// via memory_scopes yaml + the Memory.Store==nil branch when the
	// runtime hasn't configured a store backend.
	// v0.9.0 Vector Memory: construct the embedder from
	// cfg.Memory.Embedder when the operator yaml set one. Held in
	// the local `embedder` var so the admin /v1/_memory/reembed
	// endpoint (PR 4) can reach it too. When the embedder block is
	// unset, vector ops refuse with embedder_not_configured at the
	// tool layer.
	embedder, err := embedders.Build(cfg)
	if err != nil {
		log.Fatalf("embedder: %v", err)
	}
	if embedder != nil {
		log.Printf("embedder: %s/%s (dim=%d)", embedder.Provider(), embedder.Model(), embedder.Dimension())
	}

	memoryTool := &builtin.Memory{
		MaxValueBytes:     cfg.Env.MemoryMaxValueBytes,
		DefaultQuotaBytes: cfg.Env.MemoryMaxScopeBytes,
		Embedder:          embedder,
		// RFC I MR-3b: resolves a per-agent memory_backend NAME to its
		// MemoryBackendDef (static yaml or dynamic substrate Def).
		Cfg: cfg,
	}
	allTools = append(allTools, memoryTool)

	// Channel tool (v0.8.4) — persistent inter-agent message bus.
	// One Bus instance per process so in-process subscribers waiting
	// in long-poll mode get sub-millisecond notification. Same
	// Store==nil branch as Memory: tool registered unconditionally,
	// declines all ops when no store configured. Per-agent ACL via
	// `channels:` yaml + the operator-declared top-level `channels:`
	// block.
	channelBus := channels.NewBus()
	// v0.8.6 scheduler — arms time.AfterFunc timers for deferred
	// publishes so long-poll subscribers wake at visible_at. Bounded
	// by LOOMCYCLE_CHANNELS_MAX_PENDING_DEFERRED (default 10000).
	channelScheduler := channels.NewScheduler(channelBus, cfg.Env.ChannelsMaxPendingDeferred)
	channelTool := &builtin.Channel{
		Bus:           channelBus,
		Scheduler:     channelScheduler,
		MaxValueBytes: cfg.Env.ChannelsMaxValueBytes,
		LongPollCapMS: cfg.Env.ChannelsLongPollCapMS,
	}
	allTools = append(allTools, channelTool)

	// AgentDef tool (v0.8.5). Per-agent default-deny via
	// agent_def_scopes yaml; the tool itself is registered globally
	// and the policy gate runs inside Execute.
	agentDefTool := &builtin.AgentDef{
		Cfg:                 cfg,
		MaxDefinitionBytes:  cfg.Env.AgentDefMaxDefinitionBytes,
		MaxDescriptionBytes: cfg.Env.AgentDefMaxDescriptionBytes,
		MaxCodeBytes:        cfg.Env.AgentDefMaxCodeBytes,
	}
	allTools = append(allTools, agentDefTool)

	// SkillDef tool (v0.8.22). Mirror of AgentDef for runtime skill
	// mutation. Authoring is gated by the agent's RFC BA `skills:`
	// pattern allowlist (via SkillPolicy). Set is shared with the
	// SkillTool above so static-name guard + fork-bootstrap see the
	// same static skills.
	skillDefTool := &builtin.SkillDef{
		Set:                 skillSet,
		MaxBodyBytes:        cfg.Env.SkillDefMaxBodyBytes,
		MaxDescriptionBytes: cfg.Env.SkillDefMaxDescriptionBytes,
	}
	allTools = append(allTools, skillDefTool)

	// TeamDef tool (RFC AP) — MUST be in allTools so team/assistant +
	// team/orchestrator can author/run teams IN-LOOP (an agent's `tools: [TeamDef]`
	// only takes effect if the instance is agent-available; being reachable via
	// the HTTP/MCP def-route alone is not enough). Store is late-bound below;
	// Spawn + Admit (op=run) are injected by srv.SetTeamDefTool. Byte caps reuse
	// AgentDef's (the definition is a graph blob, like ScheduleDef/A2A).
	teamDefTool := &builtin.TeamDef{
		MaxDefinitionBytes:  cfg.Env.AgentDefMaxDefinitionBytes,
		MaxDescriptionBytes: cfg.Env.AgentDefMaxDescriptionBytes,
	}
	allTools = append(allTools, teamDefTool)

	// VolumeDef tool (RFC AH Phase 2a) — the dynamic-volume substrate.
	// In-loop (per-agent dispatcher) with a default-deny volume_def_scopes
	// gate, NOT operator-admin-only like MCPServerDef. Store late-bound
	// below. Cfg supplies the dynamic_root + the static-name collision
	// guard. MaxNameLen mirrors the regex ceiling (64).
	volumeDefTool := &builtin.VolumeDef{
		Cfg:        cfg,
		MaxNameLen: 64,
	}
	allTools = append(allTools, volumeDefTool)

	// RFC AR — the CredentialDef tool (secure per-tenant credential store).
	// Engine (store + Sealer) is late-bound below, once the store + the master
	// key (LOOMCYCLE_SECRET_KEY) are available. In-loop with tenant/user/agent
	// scope isolation; the plaintext value is masked from the persisted
	// transcript at store time (see maskCredentialCreateValue).
	credentialDefTool := &builtin.CredentialDef{}
	allTools = append(allTools, credentialDefTool)

	// RFC AL — the Path tool (Unix-like VFS over the dirents substrate). Store
	// late-bound below alongside the other substrate tools.
	pathTool := &builtin.Path{}
	allTools = append(allTools, pathTool)

	// RFC AK — the Document tool (chunked-graph documents). Store late-bound
	// below; SqlMem bound in the SQL Memory block (Document requires it); Bus
	// for change events. MaxAssetBytes (RFC BO) caps a stored image's decoded
	// size (0 → the tool's built-in default).
	// Embedder (RFC BU phase 1) makes chunk BODIES semantically searchable via
	// `memory op=search` with prefix "doc.chunk:". Nil when the operator declared
	// no embedder block — bodies are then written exactly as before, unsearchable
	// but never lost, so authoring never depends on an embedder being reachable.
	documentTool := &builtin.Document{
		Bus:           channelBus,
		MaxAssetBytes: int(cfg.Env.MaxDocumentAssetBytes),
		Embedder:      embedder,
	}
	allTools = append(allTools, documentTool)

	// RFC BE — the History tool (browse/search/annotate past chats). Store
	// late-bound below; the per-agent history_scope policy gates every op.
	// Cfg is read for one thing: which agents are declared `internal:`, so the
	// runtime's own maintenance sessions stay out of the chat listing.
	historyTool := &builtin.History{Cfg: cfg}
	allTools = append(allTools, historyTool)

	// MCPServerDef tool construction is deferred until after the pool
	// + dynamic registry are built (operator-admin-only — NOT appended
	// to allTools; wired via SetMCPServerDefTool after the pool exists).

	// Evaluation tool (v0.8.5). Selection half of the substrate;
	// emitter_role is derived server-side from the caller's RunIdentity
	// vs the target run's identity, and per-agent yaml `evaluation_scopes`
	// gate which roles may submit + whether read ops are allowed.
	evaluationTool := &builtin.Evaluation{
		MaxJudgementBytes: cfg.Env.EvaluationMaxJudgementBytes,
		MaxRationaleBytes: cfg.Env.EvaluationMaxRationaleBytes,
	}
	allTools = append(allTools, evaluationTool)

	// Interruption tool (v0.8.16) — human-in-the-loop primitive. Three
	// ops (ask / notify / cancel) gated by per-agent
	// `interruption: {enabled, kinds, max_pending}` yaml. The tool
	// blocks via channels.Bus, so it reuses the same bus instance the
	// Channel tool's long-poll subscribe uses. SystemPublisher (set
	// below alongside Store) carries the external `_system/interrupts/
	// pending` / `resolved` signal for non-run consumers (Web UI inbox,
	// Slack notifier).
	interruptionTool := &builtin.Interruption{
		Bus:               channelBus,
		Backend:           cfg.Interruption.Backend,
		DefaultTimeout:    time.Duration(cfg.Interruption.DefaultTimeoutMS) * time.Millisecond,
		MaxTimeout:        time.Duration(cfg.Interruption.MaxTimeoutMS) * time.Millisecond,
		HeartbeatInterval: time.Duration(cfg.Interruption.HeartbeatIntervalMS) * time.Millisecond,
		MaxPendingPerRun:  cfg.Interruption.MaxPendingPerRun,
	}
	allTools = append(allTools, interruptionTool)

	// RFC AP/BD — wire the TeamDef op=run board + interruption escalation deps
	// (both opt-in per run; Spawn + Admit are injected separately by
	// srv.SetTeamDefTool). Board persists a board-bound walk's position onto a
	// Document chunk (chunk.status = current state) + resumes from it; AskHuman
	// escalates an iteration cap to a human via the Interruption tool's `ask`
	// instead of aborting. A run that sets neither board_chunk_id nor
	// interrupt_on_cap is byte-identical to before. documentTool.SqlMem is
	// late-bound below (Board holds the *Document pointer, reading SqlMem at call
	// time), and AskHuman is invoked under the parent run's ctx so the block +
	// resolve + heartbeat all reuse the standard Interruption machinery.
	teamDefTool.Board = documentTool
	teamDefTool.AskHuman = func(ctx context.Context, question string) (string, error) {
		ask, _ := json.Marshal(map[string]any{"op": "ask", "question": question})
		res, err := interruptionTool.Execute(ctx, ask)
		if err != nil {
			return "", err
		}
		if res.IsError {
			// timeout / cancelled / not-enabled-for-agent / no-run-id — surface as
			// an error so the cap escalation fails safe to abort.
			return "", errors.New(res.Text)
		}
		var parsed struct {
			Answer string `json:"answer"`
		}
		_ = json.Unmarshal([]byte(res.Text), &parsed)
		return parsed.Answer, nil
	}

	// Context tool (v0.8.7). Read-only runtime introspection. The Tools
	// field is back-filled with the FULL allTools slice after every
	// other registration (MCP + localapi) so doc/tools ops reflect the
	// complete catalog. Including Context itself in the catalog is
	// intentional — agents introspecting "what tools do I have" should
	// see Context, and `doc(name="Context")` should work. Cfg + Store
	// power the v0.8.7 PR 2 substrate-coupled ops (agents / lineage /
	// evaluations); Store is late-bound below alongside the other
	// substrate tools.
	contextTool := &builtin.Context{
		Cfg:  cfg,
		Help: helpSet,
		// Already resolved at main() entry (ldflags → runtime/debug → "unknown"),
		// so `op=self` reports exactly what `--version` does.
		Build: builtin.BuildInfo{Version: buildVersion, Commit: buildCommit, Time: buildTime},
	}
	allTools = append(allTools, contextTool)

	// Local API MCP gateway (v0.4.0+). When `local_api.spec` is set
	// in loomcycle.yaml, parse the OpenAPI spec and register one tool
	// per operation. Each tool forwards calls to local_api.base_url
	// with the agent's `bearer` field as Authorization. Replaces the
	// curl-shaped HTTP-tool pattern Phase B agents currently use.
	if cfg.LocalAPI.SpecPath != "" {
		laTools, laWarns, err := localapi.Build(localapi.Config{
			SpecPath:       cfg.LocalAPI.SpecPath,
			BaseURL:        cfg.LocalAPI.BaseURL,
			ToolNamePrefix: cfg.LocalAPI.ToolNamePrefix,
		}, cfg.ConfigDir())
		if err != nil {
			log.Printf("local-api gateway disabled: %v", err)
		} else {
			for _, w := range laWarns {
				log.Printf("local-api: %s", w)
			}
			log.Printf("local-api: registered %d tools from %s", len(laTools), cfg.LocalAPI.SpecPath)
			allTools = append(allTools, laTools...)
		}
	}
	if len(cfg.Volumes) == 0 {
		log.Printf("note: file tools (Read/Write/Edit/Glob/Grep/Bash) require a `volumes:` binding — none configured; agents with no volume binding have no filesystem access")
	}
	if len(staticHosts) == 0 && !cfg.Env.HTTPCallerAuthoritative {
		log.Printf("note: HTTP + WebFetch tools are registered but disabled — set LOOMCYCLE_HTTP_HOST_ALLOWLIST to enable (a comma-separated host list, or `*` for all public hosts; or LOOMCYCLE_HTTP_CALLER_AUTHORITATIVE=1 to delegate the allowlist to the caller)")
	}
	for _, h := range staticHosts {
		if h == "*" {
			log.Printf("note: LOOMCYCLE_HTTP_HOST_ALLOWLIST=* — HTTP + WebFetch may reach ANY public host; the dial-time guard still blocks private/loopback/link-local/metadata IPs (add specific internal hosts via LOOMCYCLE_HTTP_PRIVATE_HOST_ALLOWLIST)")
			break
		}
	}
	if cfg.Env.HTTPCallerAuthoritative {
		log.Printf("note: HTTP_CALLER_AUTHORITATIVE=1 — caller's allowed_hosts is the sole policy; operator's static list is fallback only")
	}
	if len(cfg.Env.HTTPPrivateHostAllowlist) > 0 {
		log.Printf("note: HTTP_PRIVATE_HOST_ALLOWLIST=%v — these hosts may resolve to private IPs (e.g. localhost callbacks)", cfg.Env.HTTPPrivateHostAllowlist)
	}
	if searchRegistry == nil {
		log.Printf("note: WebSearch tool is registered but disabled — configure search_providers (or set BRAVE_API_KEY for the default Brave provider)")
	} else {
		log.Printf("search: %d provider(s) configured; priority %v", len(searchRegistry.IDs()), searchResolver.Cascade(nil))
	}
	if cfg.Env.BashEnabled {
		log.Printf("WARNING: Bash tool is enabled (LOOMCYCLE_BASH_ENABLED=1). This is NOT a true sandbox — run loomcycle inside a container or VM if you expose this to untrusted prompts.")
		log.Printf("note: Bash requires a rw volume binding — it runs in the bound volume's root and refuses an agent with no rw volume")
	} else {
		log.Printf("note: Bash tool is registered but disabled — set LOOMCYCLE_BASH_ENABLED=1 to enable (NOT a true sandbox; see docs)")
	}
	if cfg.Env.BashboxEnabled {
		log.Printf("note: Bashbox tool is enabled (LOOMCYCLE_BASHBOX_ENABLED=1) — a TRUE in-process sandbox (gbash): no OS process, paths rooted at the volume, no network; read-only volumes are honored. gbash is alpha; gate per agent via tools.")
		if len(cfg.Env.BashboxFallbackCommands) > 0 {
			log.Printf("WARNING: Bashbox host-command fallback is enabled (LOOMCYCLE_BASHBOX_FALLBACK_COMMANDS=%s). These commands ESCAPE the sandbox and run on the real host shell (with host filesystem + network) — only allowlist commands you trust, and only enable for trusted prompts. Fallback requires a read-write volume.", strings.Join(cfg.Env.BashboxFallbackCommands, ","))
		}
	} else {
		log.Printf("note: Bashbox tool is registered but disabled — set LOOMCYCLE_BASHBOX_ENABLED=1 to enable (in-process sandbox; see docs)")
	}

	// Per-agent tool policy is default-deny: an agent with no tools
	// in YAML gets ZERO tools at the dispatcher. Warn at startup so
	// operators don't discover this only when an agent inexplicably can't
	// do anything. We log per-agent rather than once so the operator sees
	// which definitions are affected.
	for name, def := range cfg.Agents {
		if len(def.Tools) == 0 {
			log.Printf("note: agent %q has no tools — it will see zero tools (intentional default-deny; add tools to its YAML to expose tools)", name)
		}
	}

	// MCP: spawn declared servers (stdio or http), discover their tools,
	// register each as `mcp__{server}__{tool}` alongside the built-ins.
	// Failures to spawn or handshake are logged and the server is skipped —
	// the other servers still come up.
	// v0.9.x dynamic MCP server registration substrate. In-process
	// registry consulted by the pool's build callback on every name
	// not found in the static yaml map. Mutated by the MCPServerDef
	// substrate tool's create / promote / retire ops; loaded from DB
	// at boot below so previous-deployment registrations survive
	// restart.
	dynamicMCPRegistry := mcp.NewDynamicRegistry()

	// RFC AR — resolves $cred:<name> tokens in an http MCP server's headers
	// against the tenant credential store, per-request from the run identity.
	// Declared here so the pool's build callback (below) captures it by reference;
	// assigned once the credential engine exists (after the store opens). Dials
	// are post-boot, so it's non-nil by the time any header is substituted.
	var credSubstitute func(ctx context.Context, s string) (string, []string, error)

	// RFC N: the pool's build callback resolves through lookup.MCPServer
	// with the run's tenant so a per-tenant dynamic registration dials its
	// OWN URL — not a shared one of the same name. A tenant-scoped dynamic
	// shadow takes precedence; otherwise the static yaml base resolves;
	// otherwise a shared ("") dynamic registration. stdio is yaml-only and
	// resolved straight from cfg (lookup.MCPServerSpec omits stdio fields).
	mcpDynView := mcpLookupView{dynamicMCPRegistry}
	mcpPool := mcp.NewPool(
		func(tenant, name string) (mcp.Caller, error) {
			// Static stdio entries are yaml-only ground truth (lookup's
			// spec can't carry Command/Args/Env). A tenant never overrides
			// a stdio server — resolve it directly at the shared base.
			if srv, ok := cfg.MCPServers[name]; ok && srv.Transport == "stdio" {
				return spawnStdioMCP(name, srv)
			}
			// http / streamable-http: tenant-dynamic → static yaml →
			// shared-dynamic, via the shared resolver.
			spec, ok := lookup.MCPServer(cfg, mcpDynView, tenant, name)
			if !ok {
				return nil, fmt.Errorf("mcp_servers.%s: not in static yaml or dynamic registry (tenant=%q)", name, tenant)
			}
			switch spec.Transport {
			case "http", "streamable-http":
				// F32: resolve ${LOOMCYCLE_*} at DIAL time, not persist time —
				// the stored def (and the registry spec) carry only the ${ref};
				// the live client gets the value. This mirrors yaml-load's
				// config.Load expansion at exactly the boundary that flattens any
				// inner ${LOOMCYCLE_*} before the request-time ${run.*}
				// substituter runs (see mcpserverdef.go buildDefinition). For a
				// yaml-static spec the values are already flat, so ExpandEnv is an
				// idempotent no-op; a pre-F32 baked row (resolved value, no ${})
				// is likewise a no-op → backward-compatible.
				hdrs := spec.Headers
				if len(hdrs) > 0 {
					hdrs = make(map[string]string, len(spec.Headers))
					for k, v := range spec.Headers {
						hdrs[k] = config.ExpandEnv(v)
					}
				}
				return mcphttp.New(mcphttp.Config{
					URL:     config.ExpandEnv(spec.URL),
					Headers: hdrs,
					// DNS-rebinding guard: OFF by default (MCP servers are commonly
					// on localhost/private-net), opt in via
					// LOOMCYCLE_MCP_ALLOW_PRIVATE_IPS=0. HTTPPrivateHostAllowlist
					// exempts specific internal MCP hosts when the block is on.
					BlockPrivateIPs:      !cfg.Env.MCPAllowPrivateIPs,
					PrivateHostAllowlist: cfg.Env.HTTPPrivateHostAllowlist,
					// RFC AR — resolve $cred:<name> header tokens per-request from
					// the tenant credential store (per-user tokens bind correctly on
					// this pooled client). credSubstitute is set below once the
					// credential engine exists (captured by ref; dials are post-boot).
					CredSubstitute: credSubstitute,
				})
			case "stdio":
				// F31: a runtime-registered stdio server. The substrate only
				// admits a stdio row when LOOMCYCLE_MCP_ALLOW_DYNAMIC_STDIO is
				// set, so reaching here already means the operator opted in.
				// Spawned + lifecycle-managed (respawn on crash, teardown on
				// retire) by the same pool path as a yaml stdio server.
				return spawnStdio(name, spec.Command, spec.Args, spec.Env)
			default:
				return nil, fmt.Errorf("mcp_servers.%s: invalid transport %q (data corruption?)", name, spec.Transport)
			}
		},
		func(c mcp.Caller) {
			// Both stdio.Client and http.Client implement Close() error.
			// A future transport that doesn't gets logged so the leak is
			// at least visible to operators.
			type closer interface{ Close() error }
			cl, ok := c.(closer)
			if !ok {
				log.Printf("mcp pool: teardown skipped (%T does not implement Close)", c)
				return
			}
			_ = cl.Close()
		},
		// RFC N tenantOf: the pool cache is keyed by (tenant, name). The
		// tenant is the authoritative principal's tenant carried on the run
		// ctx via RunIdentity (set from applyPrincipal / inherited by
		// sub-agents). RunIdentity, not the http auth package, keeps the
		// pool free of an import cycle. "" = shared/legacy tenant.
		func(ctx context.Context) string {
			return tools.RunIdentity(ctx).TenantID
		},
	)
	defer mcpPool.Close()

	// v0.9.x MCPServerDef tool — operator-admin-only. NOT appended to
	// allTools; wired into the HTTP server via SetMCPServerDefTool
	// below. Registry + Pool wired so create/promote installs entries
	// into both the DB AND the in-process registry the pool's build
	// callback consults; Pool is used for evict on retire / promote-
	// replaces and for the `rediscover` op's tools/list refresh.
	mcpServerDefTool := &builtin.MCPServerDef{
		Cfg:                 cfg,
		Registry:            dynamicMCPRegistry,
		Pool:                mcpPool,
		MaxDefinitionBytes:  cfg.Env.AgentDefMaxDefinitionBytes,
		MaxDescriptionBytes: cfg.Env.AgentDefMaxDescriptionBytes,
	}

	// Initialise each server, apply per-server tools filter, and
	// register the resulting tools alongside the built-ins.
	//
	// Budget: 30s SHARED across all servers, not per-server. With many
	// servers configured, per-server timeouts can serially stack up past
	// K8s liveness/readiness deadlines and put the pod in a restart loop
	// before /healthz ever serves once. A shared ctx caps total startup
	// blocking; remaining servers after the budget exits are skipped with
	// a clear log line — they can be retried on demand via the lazy
	// pool resolution path on the first agent run that needs them.
	//
	// GetWithRetry handles the chicken-and-egg start order: when an MCP
	// server lives behind a dependency that boots concurrently with
	// loomcycle (e.g. a Next.js dev server compiling its /api/mcp route
	// on first request), the first handshake attempt may fail with
	// ECONNREFUSED or a 404. GetWithRetry backs off (500ms, 1s, 2s, 4s,
	// 8s, 16s) until success or ctx exhaustion. Each retry logs.
	//
	// v0.8.15.1: skip the eager init entirely in `loomcycle mcp` mode.
	// The consumer (Claude Code, custom MCP orchestrator) expects the
	// stdio JSON-RPC loop to answer initialize / tools/list within
	// its short discovery timeout — well under the ~32s exponential-
	// backoff budget GetWithRetry can spend per unreachable upstream.
	// The lazy resolver wired below handles "server wasn't in pool at
	// boot" cleanly since v0.8.1; the first agent call that needs an
	// mcp__<server>__<tool> tool triggers the handshake there. Server
	// mode keeps eager init — fail-fast is correct when the operator
	// is starting a long-lived daemon.
	// Static MCP servers that failed (or, in `loomcycle mcp` mode, never attempted)
	// their boot handshake. Threaded into the run-start re-probe
	// (mcp.StaticToolsForRun) so a peer that comes up AFTER loomcycle self-heals on
	// the next run that references it — no restart. A static server's tools reach
	// the catalog ONLY via this boot loop, and the lazy resolver advertises nothing
	// to a fresh model, so without the re-probe a boot-skipped server (e.g. a
	// sidecar that started second) stays invisible until a loomcycle restart.
	// Populated here (single-threaded boot), then read-only once the enumerator is
	// set below.
	skippedStaticMCP := map[string]bool{}
	if mcpMode {
		log.Printf("mcp mode: skipping eager upstream MCP init for %d server(s); run-start re-probe + lazy resolver handshake on first agent use", len(cfg.MCPServers))
		for name := range cfg.MCPServers {
			skippedStaticMCP[name] = true
		}
	} else {
		mcpInitCtx, mcpInitCancel := context.WithTimeout(context.Background(), 30*time.Second)
		for name, srv := range cfg.MCPServers {
			_, descs, err := mcpPool.GetWithRetry(mcpInitCtx, name, log.Printf)
			if err != nil {
				log.Printf("mcp[%s]: skipped — %v — run-start re-probe will retry when a run references it", name, err)
				skippedStaticMCP[name] = true
				continue
			}
			filtered := applyToolsFilter(descs, srv.Tools)
			for _, d := range filtered {
				allTools = append(allTools, mcp.NewTool(mcpPool, name, d))
			}
			log.Printf("mcp[%s]: ready, %d/%d tools registered (transport=%s)",
				name, len(filtered), len(descs), srv.Transport)
		}
		mcpInitCancel()
	}

	// Lazy MCP recovery — when an agent calls a tool from a server that
	// failed initial handshake (skipped above), this resolver tries one
	// fresh pool.Get on the agent's call path. On success the server's
	// tools are memoised in the resolver and dispatched. Subsequent
	// calls hit the cache without re-handshaking.
	//
	// Without this, an MCP peer that's down at loomcycle boot stays
	// invisible for the lifetime of the process — operators have to
	// notice the "skipped" log line and restart loomcycle by hand.
	// In a server environment where peers (jobs-search-web, other MCP
	// services) restart independently, that's recurring operational
	// pain. See internal/tools/mcp/lazy.go for the state machine. Membership +
	// per-server tools resolve through the shared lookup.MCPServer
	// (static cfg.MCPServers → dynamic registry), so cfg + the registry are
	// passed straight through.
	mcpLazyResolver := mcp.NewLazyResolver(mcpPool, cfg, dynamicMCPRegistry, func(server string, count int) {
		log.Printf("mcp[%s]: lazy-registered %d tool(s) on first agent call", server, count)
	}, 0)

	// Storage: SQLite (default, compact installs) or Postgres
	// (production, hundreds of concurrent agents). Both adapters
	// implement the same store.Store interface; they're tested against
	// a shared contract suite in CI so they can't drift silently.
	storeIface, storeCloser, err := openStore(cfg)
	if err != nil {
		log.Fatalf("store: %v", err)
	}
	defer storeCloser()

	// v0.9.x content_sha256 backfill — populate the new column on
	// every NULL/empty row left over from a pre-v0.9.x upgrade OR
	// restored from a pre-v0.9.x snapshot. Idempotent + fast: a
	// second boot scans zero NULL rows. Failures here don't fatal
	// the start — the column just stays NULL and `verify` returns
	// matches=false until the row is naturally re-touched via
	// AgentDef set/fork.
	bfCtx, bfCancel := context.WithTimeout(context.Background(), 30*time.Second)
	if n, err := storeIface.BackfillAgentDefContentSHA256(bfCtx, backfillAgentDefSignFn); err != nil {
		log.Printf("agent_defs: backfill content_sha256 partial — %v (rows updated before error: %d)", err, n)
	} else if n > 0 {
		log.Printf("agent_defs: backfilled %d rows with content_sha256", n)
	}
	if n, err := storeIface.BackfillSkillDefContentSHA256(bfCtx, backfillSkillDefSignFn); err != nil {
		log.Printf("skill_defs: backfill content_sha256 partial — %v (rows updated before error: %d)", err, n)
	} else if n > 0 {
		log.Printf("skill_defs: backfilled %d rows with content_sha256", n)
	}
	// v0.9.x mcp_server_defs backfill — shares the same 30-second
	// bfCtx so the cumulative "boot blocked on slow store" wait is
	// bounded. Documented best-effort posture as for the other two.
	if n, err := storeIface.BackfillMCPServerDefContentSHA256(bfCtx, backfillMCPServerDefSignFn); err != nil {
		log.Printf("mcp_server_defs: backfill content_sha256 partial — %v (rows updated before error: %d)", err, n)
	} else if n > 0 {
		log.Printf("mcp_server_defs: backfilled %d rows with content_sha256", n)
	}
	// v0.9.x system_prompt_base backfill — fills the field that PR #186's
	// read-side normalizer derives at runtime. Closes the on-disk data
	// gap for legacy rows; new rows persist the field directly via the
	// write-side mergedDef.normalize() (agentdef.go). Idempotent: a
	// second boot after a full backfill finds zero rows missing the
	// field. Shares the same bfCtx — bounded boot wait.
	if n, err := storeIface.BackfillAgentDefSystemPromptBase(bfCtx); err != nil {
		log.Printf("agent_defs: backfill system_prompt_base partial — %v (rows updated before error: %d)", err, n)
	} else if n > 0 {
		log.Printf("agent_defs: backfilled %d rows with system_prompt_base", n)
	}
	bfCancel()

	// v0.11.5 yaml-static memory entries — pre-seed the substrate from
	// the operator-yaml `memory.entries:` block. Idempotent: each
	// (scope, scope_id, key) tuple is skipped if a row already exists
	// (preserves runtime updates from operators / agents that may have
	// rewritten the value between boots). Synchronous: a slow embedder
	// will delay boot — logged below per-entry so the cost is visible.
	if len(cfg.Memory.Entries) > 0 {
		bootstrapMemoryEntries(context.Background(), cfg, storeIface, embedder)
	}

	// Memory tool depends on the Store; wire the live backend in now
	// that the adapter is open. This keeps the per-agent registration
	// at boot (allTools assembled once) and the tool's nil-Store
	// fallback for operators running without a configured store.
	memoryTool.Store = storeIface
	// RFC I MR-2: this sets the operator-DEFAULT backend (the path taken
	// when an agent has no memory_backend, plus the unconditional fallback
	// for unresolved / not-yet-wired backends). Constructed here, post-store,
	// so both deps are concrete — the embedder was built above (nil when
	// unconfigured, which the backend handles by refusing vector ops exactly
	// as before). MR-3b routes PER-AGENT named backends per-call from
	// memory.go backend(ctx) via memoryTool.Cfg; this default assignment stays
	// and is what every unresolved / unknown-kind backend name degrades to.
	inProcBackend := inprocess.New(storeIface, embedder)
	// RFC BL hybrid retrieval (OQ #4): a per-replica batched access-count
	// flusher. Search records a +1 access for returned entries; the flusher
	// writes the accumulated deltas additively on a ticker + size trigger, so
	// the hot search path never blocks on the write. Stopped (with a final
	// flush) in the graceful-shutdown block below.
	accessFlusher := memory.NewAccessFlusher(storeIface, 0, 0) // 0,0 → package defaults
	accessFlusher.Start()
	inProcBackend.SetAccessFlusher(accessFlusher)
	memoryTool.Backend = inProcBackend
	channelTool.Store = storeIface
	// Wire the pool-stats accessor when the backend is Postgres so
	// the Channel tool's subscribe-race diagnostic log can correlate
	// retry fires with pool exhaustion. SQLite stores and test builds
	// leave it nil; the diagnostic log path then reports zeros for
	// the pool fields and the retry behavior is unchanged. The
	// PoolStatsFn capture lives inside execSubscribe at the case
	// <-waker: arm — see internal/tools/builtin/channel.go.
	if pg, ok := storeIface.(*storepostgres.Store); ok && pg != nil {
		channelTool.PoolStatsFn = func() (total, acquired, idle int32) {
			st := pg.Pool().Stat()
			return st.TotalConns(), st.AcquiredConns(), st.IdleConns()
		}
	}
	agentDefTool.Store = storeIface
	skillDefTool.Store = storeIface
	teamDefTool.Store = storeIface
	volumeDefTool.Store = storeIface
	// RFC AR — build the credential Sealer (per-tenant AES-GCM under the master
	// KEK) + Engine now that the store exists. A malformed LOOMCYCLE_SECRET_KEY
	// is fatal (fail-closed); an empty key leaves the inline backend disabled.
	credSealer, credErr := credential.NewSealer(cfg.Env.SecretKey, cfg.Env.SecretKeyPrevious)
	if credErr != nil {
		log.Fatalf("credential: invalid LOOMCYCLE_SECRET_KEY: %v", credErr)
	}
	credEngine := credential.NewEngine(storeIface, credSealer)
	credentialDefTool.Engine = credEngine
	// $ghapp:<name> — mint a short-lived GitHub App installation token from a
	// stored App config (the private key never leaves loomcycle; only the ~1h,
	// repo-scoped token is substituted). Resolves the App config through the same
	// scope-precedence credential lookup as $cred:.
	ghAppMinter := credential.NewGitHubAppMinter(
		func(ctx context.Context, tenantID, agentName, userID, name string) (string, bool, error) {
			res, found, err := credEngine.Resolve(ctx, tenantID, agentName, userID, name)
			if err != nil || !found {
				return "", found, err
			}
			return res.Value, true, nil
		}, nil)
	// srv is assigned below at lchttp.New; the credential closures capture it by
	// reference so a resolved value can be registered in the server's redactor.
	// registerSecret is only CALLED during a run (long after srv exists), so the
	// forward capture is safe (mirrors credSubstitute's own by-reference wiring).
	var srv *lchttp.Server
	// registerSecret masks a resolved credential value against a downstream echo:
	// a $cred: value or a tenant/user provider-key that a tool later prints would
	// otherwise land in the persisted transcript unredacted (RFC AV/AR). The
	// server-global redactor dedups by value, so re-registering is cheap.
	registerSecret := func(v string) {
		if srv != nil {
			if rd := srv.Redactor(); rd != nil {
				rd.Register(v)
			}
		}
	}
	// Wire the $cred: header resolver the MCP http pool captured by reference
	// above. Identity comes from the per-request ctx (tenant + user + agent), so
	// a pooled client binds each request's own user-scoped token.
	credSubstitute = func(ctx context.Context, s string) (string, []string, error) {
		// Fast-path: when nothing can resolve (no KEK / no external backend) skip
		// the store reads. Report any $cred: / $ghapp: refs as unresolved so the
		// caller still drops them (never send a literal token downstream).
		if !credEngine.CanResolve() {
			return s, append(credential.RefNames(s), credential.GHAppRefNames(s)...), nil
		}
		ri := tools.RunIdentity(ctx)
		// $ghapp: (mint an installation token) then $cred: (durable secret). The two
		// syntaxes don't overlap; each drops its own header on an unresolved/failed
		// ref, and a mint/resolve error aborts so no literal token is sent.
		out, un1, err := ghAppMinter.Substitute(ctx, ri.TenantID, tools.AgentName(ctx), ri.UserID, s, registerSecret)
		if err != nil {
			return "", nil, err
		}
		out, un2, err := credEngine.Substitute(ctx, ri.TenantID, tools.AgentName(ctx), ri.UserID, out, registerSecret)
		if err != nil {
			return "", nil, err
		}
		return out, append(un1, un2...), nil
	}
	// RFC AR: resolve a tenant/user credential by env-var NAME (ANTHROPIC_API_KEY,
	// BRAVE_API_KEY, …) for the run's identity, so a tenant's own key overrides
	// the operator host key (scope agent>user>tenant). Wired onto the Server
	// below; provider drivers + WebSearch consult it via the run ctx. Fails soft
	// (a lookup error → no override → host key), never blocking a run on a KEK
	// gap. Reused by every transport since gRPC routes through the http Server.
	credResolver := providers.CredentialResolver(func(ctx context.Context, name string) (providers.CredentialResolution, bool) {
		// Fast-path: no configured backend can resolve → skip the three per-call
		// scope lookups that would only ever miss (they run on EVERY LLM call).
		if !credEngine.CanResolve() {
			return providers.CredentialResolution{}, false
		}
		ri := tools.RunIdentity(ctx)
		res, found, err := credEngine.Resolve(ctx, ri.TenantID, tools.AgentName(ctx), ri.UserID, name)
		if err != nil {
			// A store/decrypt fault is NOT a legitimate miss — still fail soft to
			// the host key (never block a run on a KEK gap), but make it observable
			// so a silent bill-to-operator regression is diagnosable.
			log.Printf("credential: resolve %q failed for tenant=%s: %v", name, ri.TenantID, err)
			return providers.CredentialResolution{}, false
		}
		if !found {
			return providers.CredentialResolution{}, false
		}
		// Mask a downstream echo of the override key in the transcript.
		registerSecret(res.Value)
		// RFC AV: carry the owning scope + scope_id so a driver can tag usage
		// (operator vs tenant/user spend) from the same resolve it uses for the key.
		return providers.CredentialResolution{Value: res.Value, Scope: res.Scope, ScopeID: res.ScopeID}, true
	})
	// RFC BD: let a software team's Bashbox host-command fallback (git/gh) inject
	// PER-TENANT credentials named in LOOMCYCLE_BASHBOX_FALLBACK_ALLOWED_CREDS,
	// resolved for the run's own identity (agent>user>tenant) — so a tenant's own
	// GITHUB_TOKEN reaches git/gh instead of one shared operator host token. Only
	// the operator-allowlisted names are injected; the value goes solely into the
	// host child's env (registerSecret masks any transcript echo). Fails soft (no
	// KEK / miss → the host-env fallback still applies).
	bashboxTool.CredResolve = func(ctx context.Context, name string) (string, bool) {
		if !credEngine.CanResolve() {
			return "", false
		}
		ri := tools.RunIdentity(ctx)
		res, found, err := credEngine.Resolve(ctx, ri.TenantID, tools.AgentName(ctx), ri.UserID, name)
		if err != nil {
			log.Printf("bashbox: credential resolve %q failed for tenant=%s: %v", name, ri.TenantID, err)
			return "", false
		}
		if !found {
			return "", false
		}
		registerSecret(res.Value)
		return res.Value, true
	}
	// Raw Bash mirrors the Bashbox fallback: inject PER-TENANT credentials named
	// in LOOMCYCLE_BASH_ALLOWED_CREDS into the child's env, resolved for the run's
	// own identity (agent>user>tenant) — so a tenant's own GITHUB_TOKEN reaches
	// the command instead of one shared operator host var. Only the
	// operator-allowlisted names are injected; the value goes solely into the
	// child's env (registerSecret masks any transcript echo). Fails soft (no KEK /
	// miss → the host-env allowlist still applies).
	bashTool.CredResolve = func(ctx context.Context, name string) (string, bool) {
		if !credEngine.CanResolve() {
			return "", false
		}
		ri := tools.RunIdentity(ctx)
		res, found, err := credEngine.Resolve(ctx, ri.TenantID, tools.AgentName(ctx), ri.UserID, name)
		if err != nil {
			log.Printf("bash: credential resolve %q failed for tenant=%s: %v", name, ri.TenantID, err)
			return "", false
		}
		if !found {
			return "", false
		}
		registerSecret(res.Value)
		return res.Value, true
	}
	// RFC AX Layer 1: a metadata-only keyability probe — does (tenant, agent,
	// user) have its OWN CredentialDef for env-var `name`? Reuses Engine.Resolve's
	// agent>user>tenant precedence but NEVER decrypts (no backend.Resolve), so
	// building a restricted run's keyable-provider set costs at most three store
	// metadata reads and touches no plaintext. Fast-path false when nothing can
	// resolve (no KEK) — with no resolvable credentials no provider is
	// tenant-keyable, the correct restricted-run posture. Wired onto the Server
	// beside credResolver; consulted in the server BEFORE the lock-free resolver.
	credKeyable := func(ctx context.Context, tenantID, agentName, userID, name string) bool {
		if !credEngine.CanResolve() {
			return false
		}
		ok, err := credEngine.HasKey(ctx, tenantID, agentName, userID, name)
		if err != nil {
			log.Printf("credential: keyable check %q failed for tenant=%s: %v", name, tenantID, err)
			return false
		}
		return ok
	}
	pathTool.Store = storeIface
	documentTool.Store = storeIface
	historyTool.Store = storeIface
	// RFC BE op=related — the SAME embedder the Memory tool uses; nil (no
	// embedder configured) makes op=related refuse cleanly, every other op works.
	historyTool.Embedder = embedder
	skillTool.Store = storeIface
	evaluationTool.Store = storeIface
	contextTool.Store = storeIface
	// RFC BL P1: the SAME embedder the Memory tool uses powers the help op's
	// `query` search mode; nil (no embedder configured) makes query degrade to
	// a substring scan over the in-memory help set.
	contextTool.Embedder = embedder
	interruptionTool.Store = storeIface
	mcpServerDefTool.Store = storeIface

	// v0.9.x — load active mcp_server_defs rows into the in-process
	// registry so previous-deployment registrations survive a restart.
	// The pool's build callback consults this registry on first agent
	// call (after a tool-not-in-frozen-allTools fallthrough via the
	// existing v0.8.1 lazy resolver). Errors are logged + non-fatal —
	// a corrupted row blocks only its own server from being callable.
	if names, err := storeIface.MCPServerDefListNames(context.Background()); err == nil {
		for _, ns := range names {
			if ns.ActiveDefID == "" {
				continue
			}
			// Skip SHARED-tenant names that collide with a static yaml
			// `mcp_servers:` entry — yaml is ground truth for the "" tenant,
			// so a shared registry entry of that name would be unreachable
			// (lookup.MCPServer's "" path is static → shared-dynamic) and
			// only inflate diagnostics. A per-TENANT row (ns.TenantID != "")
			// that shares the name is a legitimate RFC N override (the
			// tenant-dynamic pass shadows the static base), so it is NOT
			// skipped. Mirrors the execCreate refusal, which only refuses
			// over yaml within the same tenant scope.
			if _, ok := cfg.MCPServers[ns.Name]; ok && ns.TenantID == "" {
				log.Printf("mcp_server_defs: skipping shared %q at boot — name collides with static yaml entry (yaml takes precedence)", ns.Name)
				continue
			}
			active, err := storeIface.MCPServerDefGet(context.Background(), ns.ActiveDefID)
			if err != nil {
				log.Printf("mcp_server_defs: load active %q (tenant=%q): %v", ns.Name, ns.TenantID, err)
				continue
			}
			// Skip retired active rows. SetRetired leaves the active overlay
			// pointing at the retired def_id (matches AgentDef/SkillDef
			// semantics — the substrate doesn't auto-clear the pointer).
			// Rehydrating a retired spec would silently revive a name the
			// operator explicitly retired.
			if active.Retired {
				log.Printf("mcp_server_defs: skipping %q (tenant=%q) at boot — active row is retired (def_id=%s)", ns.Name, ns.TenantID, active.DefID)
				continue
			}
			var ov struct {
				Transport string            `json:"transport"`
				URL       string            `json:"url"`
				Headers   map[string]string `json:"headers"`
				Command   string            `json:"command"` // stdio (F31)
				Args      []string          `json:"args"`
				Env       map[string]string `json:"env"`
			}
			if err := json.Unmarshal(active.Definition, &ov); err != nil {
				log.Printf("mcp_server_defs: parse active %q (tenant=%q): %v", ns.Name, ns.TenantID, err)
				continue
			}
			// RFC N: carry the def's tenant onto the registry entry so it is
			// keyed by (tenant, name) and only resolved for that tenant's runs.
			dynamicMCPRegistry.Set(mcp.DynamicMCPServerSpec{
				TenantID: active.TenantID, Name: active.Name, Transport: ov.Transport, URL: ov.URL, Headers: ov.Headers,
				Command: ov.Command, Args: ov.Args, Env: ov.Env, // stdio (F31)
			})
		}
		if size := dynamicMCPRegistry.Size(); size > 0 {
			log.Printf("mcp_server_defs: loaded %d active registration(s) into pool registry", size)
		}
	} else {
		log.Printf("mcp_server_defs: list active failed at boot: %v (dynamic MCP servers will be empty until first registration)", err)
	}

	// v1.x RFC G — outbound A2A: register one synthetic
	// `a2a__<peer>__<skill>` tool per (operator-registered peer,
	// expected_skill) pair, mirroring the static MCP registration above.
	// They land in allTools and are filtered per-agent by `tools`
	// exactly like `mcp__<server>__<tool>` tools. Gated behind the same
	// LOOMCYCLE_A2A_ENABLED master switch as the server surface: with A2A
	// disabled, no outbound tools are registered. The per-call resolver is
	// lookup.A2AAgent (yaml > active substrate def) so a substrate fork of
	// a registered peer is picked up without a restart.
	if cfg.Env.A2AServerEnabled {
		a2aResolve := func(ctx context.Context, name string) (config.A2AAgent, bool) {
			// RFC N: resolve the peer under the run's tenant (own → static →
			// shared ""). At boot enumeration the ctx carries no identity, so
			// this is "" (the shared/operator set); per-call Execute re-runs
			// it with the live run identity.
			return lookup.A2AAgent(ctx, storeIface, cfg, tools.RunIdentity(ctx).TenantID, name)
		}
		a2aTools := toolsa2a.RegisterTools(context.Background(), cfg, storeIface, a2aResolve, nil, log.Printf)
		if len(a2aTools) > 0 {
			allTools = append(allTools, a2aTools...)
			log.Printf("a2a: registered %d outbound peer tool(s)", len(a2aTools))
		}
	}

	// The Context tool's catalog (used by `Context op=tools`) is wired inside
	// lchttp.New — it must include the Agent tool that New appends to the
	// server's tool set, which isn't in allTools here (F45). New re-points any
	// Context tool to the COMPLETE post-append catalog, so don't set it here.
	srv = lchttp.New(cfg, pr, allTools, sem, storeIface)
	// RFC BF P2b: per-provider concurrency gates (providers.<id>.max_concurrent).
	// Acquired at run admission before the global slot; empty ⇒ every provider
	// uncapped, admission unchanged.
	srv.SetProviderGates(newProviderGates(cfg))
	// RFC AR: stamp the credential resolver onto each run so a tenant/user's own
	// provider key (ANTHROPIC_API_KEY, BRAVE_API_KEY, …) overrides the host key.
	srv.SetCredentialResolver(credResolver)
	// RFC AX: the keyability probe backs credential-aware routing for a
	// restricted run (resolves only to providers the tenant can key itself).
	srv.SetCredKeyable(credKeyable)
	// RFC BE: the History tool's op=recap summarizer. Store was late-bound above
	// (before srv existed); Recap needs server-side provider/model resolution, so
	// it's the off-loop twin of the compaction summarizer (Server.RecapSession).
	// The method value reads s.resolver lazily at call time — set below via
	// SetResolver, long before any runtime recap call.
	historyTool.Recap = srv.RecapSession
	// RFC BB: the search catalog for the routing view (GET /v1/_routing search
	// block). Same registry/resolver the WebSearch tool uses; nil when no search
	// providers are configured.
	srv.SetSearchRouting(searchRegistry, searchResolver, searchHostKeys)

	// RFC BC: the client-tool host registry — live per-principal WebSocket
	// connections over which a client executes tools on the user's machine. The
	// /v1/client-tools handler registers connections here; the dispatch fallback
	// (composed below) + candidateTools advertising read it. Always on (the
	// endpoint is bearer-gated; an empty registry just serves no client-tools).
	clientToolReg := clienttools.NewRegistry(cfg.Env.ClientToolMaxConns)
	srv.SetClientTools(clientToolReg)

	// RFC AA SQL Memory (Phase 1). Off by default; constructed only when the
	// operator enables it. The manager hosts per-scope sqlite databases that
	// SEPARATE from the main store — an agent's arbitrary SQL can only ever
	// reach its own scope file. Wired into the Memory tool (sql_query/sql_exec)
	// and the server (run-scope drop at top-level run completion).
	// Declared here (not inside the block) so the RFC BM retention sweeper below
	// can reclaim a retired agent's SQL-Memory scope; nil when SQL Memory is off.
	var sqlMemMgr *sqlmem.Manager
	if cfg.Storage.SqlMemEnabled {
		sqlMemRoot := cfg.Storage.SqlMemRoot
		if sqlMemRoot == "" {
			sqlMemRoot = filepath.Join(cfg.Env.DataDir, "sqlmem")
		}
		sqlMemCfg := sqlmem.Config{
			Root:               sqlMemRoot,
			PgDSN:              cfg.Storage.SqlMemPgDSN,
			QuotaBytes:         cfg.Storage.SqlMemQuotaBytes,
			StatementTimeoutMS: cfg.Storage.SqlMemStatementTimeoutMS,
			MaxRows:            cfg.Storage.SqlMemMaxRows,
			TxnTimeoutMS:       cfg.Storage.SqlMemTxnTimeoutMS,
			MaxOpenTxns:        cfg.Storage.SqlMemMaxOpenTxns,
			MaxTxnDepth:        cfg.Storage.SqlMemMaxTxnDepth,
			ScopeTTLMS:         cfg.Storage.SqlMemScopeTTLMS,
			GCIntervalMS:       cfg.Storage.SqlMemGCIntervalMS,
			TotalMaxBytes:      cfg.Storage.SqlMemTotalMaxBytes,
			SharedSchemas:      cfg.Storage.SqlMemSharedSchemas,
		}
		// SQL Memory FOLLOWS the main store backend (RFC AA decision 4): a
		// postgres deploy gets schema-per-scope in the SEPARATE aux DB; a
		// sqlite deploy gets file-per-scope. The aux DSN is required on
		// postgres and ignored on sqlite.
		var (
			smErr      error
			sqlMemTier string
		)
		if cfg.Storage.Backend == "postgres" {
			if strings.TrimSpace(cfg.Storage.SqlMemPgDSN) == "" {
				log.Fatalf("sqlmem: SQL Memory on a postgres backend requires a separate aux DSN — set LOOMCYCLE_SQLMEM_PG_DSN (a DIFFERENT database from the main store)")
			}
			sqlMemMgr, smErr = sqlmem.NewPostgres(context.Background(), sqlMemCfg)
			sqlMemTier = "postgres (schema-per-scope, aux DB)"
		} else {
			if strings.TrimSpace(cfg.Storage.SqlMemPgDSN) != "" {
				log.Printf("sqlmem: LOOMCYCLE_SQLMEM_PG_DSN is set but the backend is sqlite — ignoring it (file-per-scope at %s)", sqlMemRoot)
			}
			if len(cfg.Storage.SqlMemSharedSchemas) > 0 {
				log.Printf("sqlmem: sqlmem_shared_schemas is set but the backend is sqlite — ignoring it (read-only shared schemas are a postgres-tier feature, RFC AA Phase 3g)")
			}
			sqlMemMgr, smErr = sqlmem.New(sqlMemCfg)
			sqlMemTier = "sqlite (file-per-scope, root=" + sqlMemRoot + ")"
		}
		if smErr != nil {
			log.Fatalf("sqlmem: %v", smErr)
		}
		// Audit sink: reuse the operator audit log file when configured (the
		// records carry a distinct action=sql_query/sql_exec), else NopSink.
		var sqlAudit audit.Sink = audit.NopSink{}
		if cfg.Env.AuditLogPath != "" {
			fs, aerr := audit.NewFileSink(cfg.Env.AuditLogPath)
			if aerr != nil {
				log.Fatalf("sqlmem audit: %v", aerr)
			}
			sqlAudit = fs
		}
		memoryTool.SqlMem = sqlMemMgr
		documentTool.SqlMem = sqlMemMgr
		// Context op=capabilities reports SQL Memory / Documents availability
		// from the CONSTRUCTED manager, not the config flag.
		contextTool.SqlMem = sqlMemMgr
		memoryTool.SqlAudit = sqlAudit
		memoryTool.SqlAuditMode = cfg.Storage.SqlMemAuditMode
		// Reuse the server's redactor (same secret-classified env) so an
		// audited statement is masked exactly like the rest of the transcript.
		memoryTool.Redactor = srv.Redactor()
		srv.SetSqlMem(sqlMemMgr)
		// Close the manager (all open scope handles) on shutdown, after the
		// store closer so an in-flight finish-path drop still has a live handle.
		defer func() { _ = sqlMemMgr.Close() }()
		log.Printf("sqlmem: SQL Memory enabled (tier=%s, quota_bytes=%d, max_rows=%d, timeout_ms=%d, audit=%s)",
			sqlMemTier, cfg.Storage.SqlMemQuotaBytes, cfg.Storage.SqlMemMaxRows,
			cfg.Storage.SqlMemStatementTimeoutMS, cfg.Storage.SqlMemAuditMode)
	}

	// v0.9.x — wire the MCPServerDef substrate tool. NOT in allTools
	// (operator-admin-only); reached via Connector.MCPServerDef + the
	// admin endpoint + the LoomCycle MCP meta-tool.
	srv.SetMCPServerDefTool(mcpServerDefTool)
	// v1.x — wire the ScheduleDef substrate tool. Same operator-admin-
	// only posture; reached via Connector.ScheduleDef + the admin
	// endpoint + the future LoomCycle MCP meta-tool. Tool needs only
	// the store + cfg (no MCP-pool dependency) so this could move
	// earlier — kept next to MCPServerDef for substrate-wiring locality.
	srv.SetScheduleDefTool(&builtin.ScheduleDef{
		Store:               storeIface,
		Cfg:                 cfg,
		MaxDefinitionBytes:  cfg.Env.AgentDefMaxDefinitionBytes,
		MaxDescriptionBytes: cfg.Env.AgentDefMaxDescriptionBytes,
	})
	// RFC AP Phase 2 — wire the TeamDef substrate tool (team-workflow graphs).
	// Tenant-confined at the route (ScopeTenant), but the same dedicated-slot
	// wiring as ScheduleDef; reached via Connector.TeamDef + POST /v1/_teamdef +
	// the LoomCycle MCP meta-tool `teamdef`. Reuses the AgentDef byte caps (the
	// definition is a graph blob, like ScheduleDef/A2A — no dedicated env knob).
	// Same instance that's agent-available in allTools — SetTeamDefTool injects
	// Spawn + Admit (op=run) into it, and keeps the HTTP /v1/_teamdef + MCP
	// `teamdef` + Connector surfaces pointing at it.
	srv.SetTeamDefTool(teamDefTool)
	// v1.x RFC G — wire the two A2A substrate tools. Same operator-admin-
	// only posture as ScheduleDef; reached via Connector + the admin
	// endpoints + the LoomCycle MCP meta-tools. Identical Store + Cfg +
	// byte-cap construction.
	srv.SetA2AServerCardDefTool(&builtin.A2AServerCardDef{
		Store:               storeIface,
		Cfg:                 cfg,
		MaxDefinitionBytes:  cfg.Env.AgentDefMaxDefinitionBytes,
		MaxDescriptionBytes: cfg.Env.AgentDefMaxDescriptionBytes,
	})
	srv.SetA2AAgentDefTool(&builtin.A2AAgentDef{
		Store:               storeIface,
		Cfg:                 cfg,
		MaxDefinitionBytes:  cfg.Env.AgentDefMaxDefinitionBytes,
		MaxDescriptionBytes: cfg.Env.AgentDefMaxDescriptionBytes,
	})
	// v1.x RFC H — wire the WebhookDef substrate tool. Same operator-
	// admin-only posture as the A2A substrate tools; reached via
	// Connector.WebhookDef + the admin endpoint + the LoomCycle MCP
	// meta-tool. Identical Store + Cfg + byte-cap construction.
	srv.SetWebhookDefTool(&builtin.WebhookDef{
		Store:               storeIface,
		Cfg:                 cfg,
		MaxDefinitionBytes:  cfg.Env.AgentDefMaxDefinitionBytes,
		MaxDescriptionBytes: cfg.Env.AgentDefMaxDescriptionBytes,
	})
	// RFC I MR-3a — wire the MemoryBackendDef substrate tool. Same
	// operator-admin-only posture as WebhookDef; reached via
	// Connector.MemoryBackendDef + the admin endpoint + the LoomCycle
	// MCP meta-tool. Identical Store + Cfg + byte-cap construction.
	// Nothing consumes the Def yet — the per-agent routing + factory
	// land in MR-3b.
	srv.SetMemoryBackendDefTool(&builtin.MemoryBackendDef{
		Store:               storeIface,
		Cfg:                 cfg,
		MaxDefinitionBytes:  cfg.Env.AgentDefMaxDefinitionBytes,
		MaxDescriptionBytes: cfg.Env.AgentDefMaxDescriptionBytes,
	})
	// RFC L OSS multi-tenant authorization — wire the OperatorTokenDef
	// substrate tool (auth-token minting/rotation/retirement). Audit sink
	// is a file when LOOMCYCLE_AUDIT_LOG_PATH is set, else a NopSink. The
	// rotation grace defaults to 24h (override via
	// LOOMCYCLE_OPERATOR_TOKEN_ROTATION_GRACE_SECONDS). Nothing consumes
	// the tokens yet — the auth-middleware switch lands in the next PR.
	var tokenAudit audit.Sink = audit.NopSink{}
	if cfg.Env.AuditLogPath != "" {
		fs, aerr := audit.NewFileSink(cfg.Env.AuditLogPath)
		if aerr != nil {
			log.Fatalf("audit: %v", aerr)
		}
		tokenAudit = fs
		log.Printf("audit: OperatorTokenDef mutations → %s", cfg.Env.AuditLogPath)
	}
	graceSecs := 0
	if v := os.Getenv("LOOMCYCLE_OPERATOR_TOKEN_ROTATION_GRACE_SECONDS"); v != "" {
		if n, perr := strconv.Atoi(v); perr == nil && n >= 0 {
			graceSecs = n
		}
	}
	srv.SetOperatorTokenDefTool(&builtin.OperatorTokenDef{
		Store:                storeIface,
		Pepper:               cfg.Env.OperatorTokenPepper,
		Audit:                tokenAudit,
		RotationGraceSeconds: graceSecs,
		LegacyTokenSet:       cfg.Env.AuthToken != "",
	})
	// RFC L Decision 11: per-replica auth-token resolution cache. Default
	// 30s TTL; LOOMCYCLE_AUTH_CACHE_TTL_SECONDS=0 disables it (direct
	// lookup per request — immediate revocation). A token mutation
	// flushes it locally + (in cluster mode) broadcasts a flush.
	authCacheTTL := 30
	if v := os.Getenv("LOOMCYCLE_AUTH_CACHE_TTL_SECONDS"); v != "" {
		if n, perr := strconv.Atoi(v); perr == nil && n >= 0 {
			authCacheTTL = n
		}
	}
	srv.EnableTokenCache(time.Duration(authCacheTTL) * time.Second)
	// Surface the resolved build identifiers via /healthz so the Web UI
	// can render the running binary's real version instead of a stale
	// hard-coded string. Mirrors what gRPC's Health RPC has reported
	// since v0.5.5; HTTP only adopted this in v0.8.21.
	srv.SetBuildInfo(buildVersion, buildCommit, buildTime)
	srv.SetMCPFallback(mcpLazyResolver.Resolve)
	// v0.9.x: expose Pool's cached tools/list snapshots to the unified
	// /v1/_library/mcp-servers endpoint so static MCP servers' tools
	// surface alongside substrate-side discovered_tools. The closure
	// marshals into the substrate-mirror shape ({name, description,
	// input_schema}) so the wire stays uniform across static + dynamic.
	srv.SetMCPPoolInspector(func(name string) json.RawMessage {
		// Static yaml MCP servers live at the shared "" tenant in the
		// (tenant, name)-keyed pool. The Library introspection endpoint
		// surfaces those operator-blessed servers' cached tool lists.
		descs := mcpPool.PeekTools("", name)
		if descs == nil {
			return nil
		}
		type td struct {
			Name        string          `json:"name"`
			Description string          `json:"description,omitempty"`
			InputSchema json.RawMessage `json:"input_schema,omitempty"`
		}
		out := make([]td, len(descs))
		for i, d := range descs {
			out[i] = td{Name: d.Name, Description: d.Description, InputSchema: d.InputSchema}
		}
		b, _ := json.Marshal(out)
		return b
	})
	// Post-boot tool advertising: enumerate the substrate-registered tools
	// (dynamic MCP servers' discovered tools + A2A peer skills) so a run's
	// catalog includes them WITHOUT a restart — symmetric with boot-time
	// tools. Run-creation folds this into the candidate set before the
	// tools filter. MCP: each dynamic-registry server's persisted
	// discovered_tools (set by rediscover). A2A: re-enumerate static +
	// substrate (the static dups collapse in the by-name filter).
	//
	// RFC N §3 tenant boundary: enumerate ONLY the names visible to the
	// run's tenant (NamesForTenant = own + shared), and read each name's
	// active def with the tenant→shared precedence. NET EFFECT: a run in
	// tenant A's candidate set contains ONLY A's + shared MCP tools, never
	// tenant B's.
	//
	// RFC N FIX 2-mcp: the tenant is the run's AUTHORITATIVE tenant passed
	// in by the server, NOT derived from ctx here. candidateTools runs at
	// the entry sites before WithRunIdentity is stamped, so a ctx-derived
	// tenant would be "" for non-HTTP-principal spawn surfaces and the run
	// would advertise the wrong tenant's MCP tools. The tenant remains
	// authoritative (the server derived it from the principal / session /
	// parent RunIdentity), never from the wire.
	srv.SetDynamicToolEnumerator(func(ctx context.Context, tenant string, wantServers map[string]bool) []tools.Tool {
		// F33: dynamic MCP enumeration (cached tools for every server; a
		// bounded run-start handshake for a REFERENCED server whose cache is
		// empty) lives in the testable mcp.DynamicToolsForRun helper.
		out := mcp.DynamicToolsForRun(ctx, mcpPool, dynamicMCPRegistry, mcpActiveDefReader{storeIface}, tenant, wantServers, runStartMCPDiscoveryTimeout, log.Printf)
		// Recover STATIC (yaml) MCP servers skipped at boot without a restart —
		// the static sibling of the F33 dynamic re-probe above. Scoped to the
		// servers this run references (wantServers) and to those that actually
		// failed boot (skippedStaticMCP), so a healthy server is never re-dialed.
		out = append(out, mcp.StaticToolsForRun(ctx, mcpPool, cfg.MCPServers, skippedStaticMCP, tenant, wantServers, runStartMCPDiscoveryTimeout, log.Printf)...)
		if cfg.Env.A2AServerEnabled {
			resolve := func(ctx context.Context, name string) (config.A2AAgent, bool) {
				// RFC N: the enumerator's `tenant` is authoritative (server
				// derived it from the principal/session/parent), so resolve the
				// per-run peer set under it rather than re-deriving from ctx.
				return lookup.A2AAgent(ctx, storeIface, cfg, tenant, name)
			}
			out = append(out, toolsa2a.RegisterTools(ctx, cfg, storeIface, resolve, nil, log.Printf)...)
		}
		return out
	})
	// v0.8.22: hand the static skills.Set to the server so the
	// per-run SkillDef resolver can fall back to static bodies when
	// no DB-active row exists for a skill name.
	srv.SetSkillSet(skillSet)
	// v0.9.0: hand the embedder to the server so the
	// /v1/_memory/reembed + /v1/_memory/embed_stats admin endpoints
	// have a backing object. Nil-safe — endpoints return 503 when
	// no embedder is configured.
	srv.SetEmbedder(embedder)

	// v0.8.6 SystemPublisher — backs the POST /v1/_channels/_system/...
	// admin endpoint AND the cadence/event-hook publishers added in
	// PR 3. Wires the same Bus + Scheduler as the agent tool so
	// long-poll subscribers wake whether the publish came from an
	// agent, an internal goroutine, or the admin endpoint.
	sysPublisher := &channels.StorePublisher{
		Store:     storeIface,
		Bus:       channelBus,
		Scheduler: channelScheduler,
	}
	srv.SetSystemPublisher(sysPublisher)
	// Wire the SystemPublisher onto the Interruption tool too — same
	// instance, so _system/interrupts/* publishes from inside the
	// tool wake the same Channel long-poll subscribers.
	interruptionTool.SystemPublisher = sysPublisher
	// v0.8.16 — wire the same Bus to the server so the resolve
	// handler can wake the blocked tool's bus.Wait via the
	// "intr:<id>" key. Without this the resolve writes the row but
	// the tool re-checks storage only when its own timer fires.
	srv.SetInterruptionBus(channelBus)
	// PR 2 / interactive terminal — operator mid-run steering registry.
	// Enables POST /v1/runs/{run_id}/input to inject an instruction into an
	// in-flight run. Default per-run buffer depth (16); single-replica
	// (cross-replica routing is a later phase).
	srv.SetSteerRegistry(steer.NewRegistry(0))
	// v0.9.x — same Bus also drives the Channel CRUD subscribe path
	// (Connector + HTTP /v1/_channels/{name}/subscribe). Wire-side
	// subscribers wake on the same Notify() the in-band tool would.
	srv.SetChannelBus(channelBus)

	// v0.9.x n8n RFC Phase 0 — run-state pub/sub bus that backs the
	// GET /v1/users/{user_id}/agents/stream SSE endpoint. One
	// instance per process; subscribers (typically the SSE handler)
	// register per user_id; the finishRun* paths publish state
	// transitions. Decoupled from channelBus so a slow run-state
	// consumer can't stall the channel notification path.
	runStateBus := runstate.NewBus()
	srv.SetRunStateBus(runStateBus)

	// v0.8.6 heartbeat runner — one goroutine per `_system/heartbeat-*`
	// (or any other `publisher: system` + `period:` channel) declared
	// in operator yaml. Construction happens here; Start is deferred
	// to AFTER bgCtx is created below so the runner observes the same
	// shared shutdown context as the sweepers + session-lock GC.
	// That way bgCancel() on SIGTERM tears the heartbeat goroutines
	// down naturally, and any future v0.8.9 pause path that cancels
	// bgCtx pauses heartbeats without a separate hook.
	heartbeatChannels := make(map[string]struct {
		Period      string
		Publisher   string
		DefaultTTL  int
		MaxMessages int
	}, len(cfg.Channels))
	for name, ch := range cfg.Channels {
		heartbeatChannels[name] = struct {
			Period      string
			Publisher   string
			DefaultTTL  int
			MaxMessages int
		}{
			Period:      ch.Period,
			Publisher:   ch.Publisher,
			DefaultTTL:  ch.DefaultTTL,
			MaxMessages: ch.MaxMessages,
		}
	}
	heartbeatSpecs, err := channels.LoadHeartbeatSpecs(heartbeatChannels)
	if err != nil {
		log.Fatalf("heartbeat specs: %v", err)
	}
	var heartbeatRunner *channels.HeartbeatRunner
	if len(heartbeatSpecs) > 0 {
		heartbeatRunner = channels.NewHeartbeatRunner(sysPublisher, buildCommit, heartbeatSpecs)
	}

	// Build the model-resolution matrix (resolve.Resolver). Providers
	// without API keys are MARKED EXCLUDED so Snapshot() shows the
	// distinct "no key configured" state — the resolver skips them
	// like unreachable providers but operators can tell the two
	// apart in logs and dashboards. Providers with keys are probed
	// live (GET /v1/models or /api/tags) and seeded from the
	// response. The probe goroutine started below re-runs the probe
	// on the configured cadence to catch recoveries.
	resolver := buildResolver(cfg, pr)
	srv.SetResolver(resolver)

	// v1.x RFC G — A2A server surface (well-known AgentCard + REST /
	// JSON-RPC / gRPC binding mounts + multi-tenant routing). Default
	// OFF; New returns (nil, nil) when LOOMCYCLE_A2A_ENABLED != 1. The
	// *http.Server (srv) is BOTH the runner.Runner and the
	// connector.Connector the bridge needs, plus the run-table reader.
	// Mount must be registered BEFORE srv.Mux() is called below; gRPC
	// registration happens on the shared grpc.Server further down.
	var a2aServer *a2aapi.Server
	if cfg.Env.A2AServerEnabled {
		// Authenticate A2A peers through the SAME operator-token substrate the
		// HTTP/gRPC planes use — NOT just the legacy LOOMCYCLE_AUTH_TOKEN.
		// srv.ResolvePrincipal handles both operator tokens and the legacy
		// fallback (and correctly rejects a legacy secret HTTP has retired once
		// an admin token exists); srv.AuthConfigured is the shared open-mode
		// decision, evaluated per request. Previously this was gated on the
		// legacy token alone, so an operator-token-only deployment left A2A
		// unauthenticated (deps.Auth nil → anonymous-but-authenticated).
		// RFC AX: pass the deployment gate so the frontier derives each peer's
		// operator-key restriction from its own scopes (anti-bypass for A2A).
		a2aAuth := a2aapi.FrontierAuthenticator(srv.AuthConfigured, srv.ResolvePrincipal, cfg.Env.OperatorKeyRestriction)
		a2aServer, err = a2aapi.New(context.Background(), a2aapi.Deps{
			Cfg:   cfg,
			Store: storeIface,
			Conn:  srv,
			Run:   srv,
			Auth:  a2aAuth,
			// Same bus the Interruption tool waits on + the HTTP resolve
			// handler notifies (SetInterruptionBus(channelBus) above), so an
			// A2A INPUT_REQUIRED follow-up wakes the parked run on the same
			// "intr:<id>" key.
			ChannelNotify: channelBus.Notify,
		})
		if err != nil {
			log.Fatalf("a2a server: %v", err)
		}
		if a2aServer != nil {
			srv.SetExtraMux(a2aServer.Mount)
			log.Printf("a2a: server surface enabled (card=%q tenancy=%q)", cfg.Env.A2AServerCardName, cfg.Env.A2ATenancyRouting)
		}
	}

	// v1.x RFC H inbound-webhook receiver. Default OFF; operator opts in
	// via LOOMCYCLE_WEBHOOKS_ENABLED=1. The receiver is the trust boundary:
	// it authenticates each external POST against the resolved WebhookDef's
	// own HMAC/bearer secret (gated by the SAME env allowlist the scheduler
	// uses — the shared RFC F trigger-credential gate), then spawns a run
	// (srv as runner) or publishes to a channel (sysPublisher). ?sync runs
	// block on runStateBus. The route mounts WITHOUT the global bearer auth.
	//
	// Registered BEFORE srv.Mux() below — the SetWebhookMux hook fires
	// inside Mux(), same ordering constraint as SetExtraMux for A2A.
	if cfg.Env.WebhooksEnabled && storeIface != nil && srv != nil {
		// The receiver's secret/credential allowlist is the union of the
		// explicit operator knobs (LOOMCYCLE_SCHEDULER_ENV_ALLOWLIST +
		// LOOMCYCLE_WEBHOOKS_ENV_ALLOWLIST) and every secret env declared by a
		// STATIC webhook — so an operator-authored webhook resolves its own
		// secret without a separate allowlist entry (the F23 trap). A
		// LOOMCYCLE_*-named VERIFY secret resolves via the namespace auto-allow
		// in resolveSecret even when not seeded here.
		webhookAllowlist := webhookapi.BuildEnvAllowlist(cfg)
		rec := webhookapi.New(webhookapi.Deps{
			Store:                storeIface,
			Cfg:                  cfg,
			Runner:               srv,
			Publisher:            sysPublisher,
			RunStateBus:          runStateBus,
			EnvAllowlist:         webhookAllowlist,
			AllowUnauthenticated: cfg.Env.WebhooksAllowUnauthenticated,
			Logf:                 log.Printf,
		})
		srv.SetWebhookMux(func(reg lchttp.MuxRegistrar, adminAuth func(http.Handler) http.Handler) {
			// Receiver POST: unauthed (per-WebhookDef secret). Triage
			// endpoints (recent-deliveries / test): admin-bearer gated.
			rec.Mount(reg)
			rec.MountAdmin(reg, adminAuth)
		})
		log.Printf("webhooks: enabled (POST /v1/_webhooks/{name}, env_allowlist=%d names via LOOMCYCLE_SCHEDULER_ENV_ALLOWLIST + LOOMCYCLE_WEBHOOKS_ENV_ALLOWLIST + static-declared secrets; LOOMCYCLE_* verify-secrets auto-allowed; unauthenticated_mode=%t)",
			len(webhookAllowlist), cfg.Env.WebhooksAllowUnauthenticated)
		// Surface every static webhook that will fail every delivery (inert,
		// or its secret won't resolve) so the operator sees WHY at boot rather
		// than discovering it via a 503 — the F23/F24 discoverability gap.
		for _, warn := range webhookapi.UnresolvableStaticSecrets(cfg, webhookAllowlist, os.Getenv) {
			log.Printf("webhooks: WARNING: %s", warn)
		}
		// Materialize static yaml `webhooks:` into the webhook_defs substrate
		// (F24) — parity with agents/skills/schedules, so a static webhook is
		// listable/forkable in the Library without a prior fork. Idempotent +
		// fork-respecting. A minimal tool instance (Store + Cfg) suffices.
		bootWH := &builtin.WebhookDef{Store: storeIface, Cfg: cfg}
		bootWHCtx := tools.WithRunIdentity(context.Background(), tools.RunIdentityValue{AgentID: "webhook-bootstrap"})
		if n, err := bootWH.BootstrapStaticWebhooks(bootWHCtx); err != nil {
			log.Printf("webhooks: static-webhook bootstrap: %v (continuing)", err)
		} else if n > 0 {
			log.Printf("webhooks: materialized %d static webhook(s) into the substrate", n)
		}
	} else if cfg.Env.WebhooksEnabled {
		log.Printf("webhooks: disabled (no Store backend or no HTTP server)")
	} else {
		log.Printf("webhooks: disabled (LOOMCYCLE_WEBHOOKS_ENABLED=0)")
	}

	// In path-mode A2A tenancy, the leading /{tenant} segment is stripped
	// before the mux sees the request (an open first-segment wildcard
	// cannot coexist with the HTTP server's subtree routes). The wrapper
	// is a no-op outside path mode and when A2A is disabled.
	var rootHandler http.Handler = srv.Mux()
	if a2aServer != nil {
		rootHandler = a2aServer.PathTenantWrapper(rootHandler)
	}

	httpServer := &http.Server{
		Addr:              cfg.Env.ListenAddr,
		Handler:           rootHandler,
		ReadHeaderTimeout: 10 * time.Second,
		// Bound idle keep-alive connections so abandoned clients don't
		// accumulate open fds over time. ReadTimeout/WriteTimeout stay OFF
		// deliberately: SSE run streams + long-poll subscribes are long-lived
		// and a read/write deadline would sever them mid-stream. IdleTimeout
		// only fires between requests on a keep-alive connection, so it's
		// SSE-safe.
		IdleTimeout: 120 * time.Second,
	}

	if cfg.Env.AuthToken == "" {
		log.Printf("WARNING: LOOMCYCLE_AUTH_TOKEN is not set; /v1 routes are unauthenticated (dev mode)")
	}

	// Background goroutines (heartbeat sweeper + session-lock map GC)
	// share a single ctx tied to the signal handler below — graceful
	// shutdown cancels them alongside the HTTP server. We rely on the
	// goroutines to exit promptly on ctx.Done(); both are documented
	// to do so in their packages.
	bgCtx, bgCancel := context.WithCancel(context.Background())
	defer bgCancel()

	// v0.8.6 heartbeat runner — start AFTER bgCtx is available so
	// bgCancel() on SIGTERM (or a future pause hook) cancels the
	// heartbeat goroutines naturally.
	if heartbeatRunner != nil {
		log.Printf("system channels: starting %d heartbeat goroutine(s)", len(heartbeatSpecs))
		heartbeatRunner.Start(bgCtx)
	}

	// v0.12.0 multi-replica HA foundation. Activates only when the
	// operator sets LOOMCYCLE_REPLICA_ID. openStore already guards
	// against SQLite + cluster mode, so reaching here with a non-
	// empty ReplicaID guarantees a Postgres store.
	//
	// advisoryLock is declared outside the cluster block so the
	// sweeper launch sites below can pass it as nil in single-replica
	// mode (no locking) or the real *AdvisoryLock in cluster mode
	// (only one replica sweeps per tick). v0.12.4 Phase 5.
	var advisoryLock *coord.AdvisoryLock
	if cfg.Env.ReplicaID != "" {
		pgStore, ok := storeIface.(*storepostgres.Store)
		if !ok {
			log.Fatalf("coord: REPLICA_ID set but store is not *storepostgres.Store (openStore guard bypassed?)")
		}
		bp, err := coord.NewPostgresBackplane(coord.PostgresBackplaneConfig{
			Pool:      pgStore.Pool(),
			DSN:       cfg.Storage.PgDSN,
			ReplicaID: cfg.Env.ReplicaID,
		})
		if err != nil {
			log.Fatalf("coord: backplane init: %v", err)
		}
		defer func() {
			if err := bp.Close(); err != nil {
				log.Printf("coord: backplane close: %v", err)
			}
		}()
		replicaStore := coord.NewReplicaStore(pgStore.Pool())
		hostname, _ := os.Hostname()
		hb := coord.NewHeartbeat(replicaStore, coord.HeartbeatConfig{
			ReplicaID: cfg.Env.ReplicaID,
			Hostname:  hostname,
			Version:   buildVersion,
		})
		go hb.Run(bgCtx)
		// Wire the cluster view onto /healthz. Phase 1 ships the
		// backplane behind the interface but with no live publisher/
		// subscriber — Phase 2+ adds those.
		srv.SetCoord(bp, replicaStore, cfg.Env.ReplicaID)

		// v0.12.1 Phase 2: cluster-wide per-user fairness. Replaces the
		// in-process perUser counter inside Semaphore with a DB-backed
		// user_quotas atomic counter. No-op when MaxConcurrentRunsPerUser
		// is 0 (per-user check disabled); the wiring is unconditional
		// because WithUserQuotaStore is cheap and the gate is only
		// reached when perUserActive evaluates true.
		quotaStore := coord.NewUserQuotaStore(pgStore.Pool())
		sem.WithUserQuotaStore(quotaStore)

		// v0.12.2 Phase 3: cross-replica cancel coordinator. When a
		// cancel hits a replica that doesn't own the run, the registry
		// delegates to CancelCoordinator.CancelRemote which broadcasts
		// on the backplane + awaits an ack from the owning replica.
		// Two long-lived subscribers carry the wire side: one listens
		// for incoming cancel requests (RunCancelSubscriber), the other
		// receives acks (RunAckSubscriber).
		ackTimeout := time.Duration(cfg.Env.CancelAckTimeoutMs) * time.Millisecond
		cancelCoord, err := coord.NewCancelCoordinator(coord.CancelCoordinatorConfig{
			Backplane:    bp,
			ReplicaID:    cfg.Env.ReplicaID,
			Store:        pgStore,
			ReplicaStore: replicaStore,
			AckTimeout:   ackTimeout,
		})
		if err != nil {
			log.Fatalf("coord: cancel coordinator init: %v", err)
		}
		srv.CancelRegistry().SetClusterCanceller(cancelCoord)
		go cancelCoord.RunCancelSubscriber(bgCtx, srv.CancelRegistry())
		go cancelCoord.RunAckSubscriber(bgCtx)

		// Cross-replica operator steering (the twin of cancel): a
		// POST /v1/runs/{run_id}/input that lands on a replica not running the
		// target run routes over the backplane to the owning replica.
		if steerReg := srv.SteerRegistry(); steerReg != nil {
			steerCoord, err := coord.NewSteerCoordinator(coord.SteerCoordinatorConfig{
				Backplane:    bp,
				ReplicaID:    cfg.Env.ReplicaID,
				Store:        pgStore,
				ReplicaStore: replicaStore,
				AckTimeout:   ackTimeout,
			})
			if err != nil {
				log.Fatalf("coord: steer coordinator init: %v", err)
			}
			steerReg.SetClusterSteerer(steerCoord)
			go steerCoord.RunSteerSubscriber(bgCtx, steerReg)
			go steerCoord.RunSteerAckSubscriber(bgCtx)
		}

		// Cross-replica turn-cancel (RFC BH P3a; the twin of cancel/steer): a
		// POST /v1/runs/{run_id}/cancel that lands on a replica not running the
		// target run routes over the backplane to the owning replica by
		// runs.replica_id, so a turn-cancel works cluster-wide.
		if tcReg := srv.TurnCancelRegistry(); tcReg != nil {
			tcCoord, err := coord.NewTurnCancelCoordinator(coord.TurnCancelCoordinatorConfig{
				Backplane:    bp,
				ReplicaID:    cfg.Env.ReplicaID,
				Store:        pgStore,
				ReplicaStore: replicaStore,
				AckTimeout:   ackTimeout,
			})
			if err != nil {
				log.Fatalf("coord: turn-cancel coordinator init: %v", err)
			}
			tcReg.SetClusterCanceller(tcCoord)
			go tcCoord.RunTurnCancelSubscriber(bgCtx, tcReg)
			go tcCoord.RunTurnCancelAckSubscriber(bgCtx)
		}

		// v0.12.3 Phase 4: runstate + channel bus backplane fanout.
		// Both buses were constructed earlier (lines ~1003/1012) and
		// wired into the server via SetRunStateBus / SetChannelBus.
		// Here we add the cluster-mode fanout: every local Publish/
		// Notify also goes onto the backplane so remote replicas wake
		// their local subscribers (PostgresBackplane self-filter
		// prevents the originator from looping).
		if rsb := srv.RunStateBus(); rsb != nil {
			rsb.SetBackplane(bp)
			if err := rsb.SubscribeBackplane(bgCtx, bp); err != nil {
				log.Fatalf("coord: runstate bus subscribe: %v", err)
			}
		}
		if cb := srv.ChannelBus(); cb != nil {
			cb.SetBackplane(bp)
			if err := cb.SubscribeBackplane(bgCtx, bp); err != nil {
				log.Fatalf("coord: channel bus subscribe: %v", err)
			}
		}
		// RFC L Decision 11: flush the local auth-token cache when a peer
		// replica creates/rotates/retires a token.
		if err := srv.SubscribeTokenInvalidations(bgCtx, bp); err != nil {
			log.Fatalf("coord: token-cache invalidation subscribe: %v", err)
		}

		// v0.12.4 Phase 5: advisory-lock helper + replicas TTL sweeper.
		// AdvisoryLock gates the other sweepers below; ReplicasSweeper
		// reaps dead replicas + closes Phases 2 and 3 crash-safety gaps.
		advisoryLock = coord.NewAdvisoryLock(pgStore.Pool())
		replicasSweeper := coord.NewReplicasSweeper(pgStore.Pool(), coord.ReplicasSweeperConfig{
			Lock:       advisoryLock,
			Interval:   cfg.Env.ReplicasSweepInterval,
			StaleAfter: cfg.Env.ReplicasStaleAfter,
		})
		go replicasSweeper.Run(bgCtx)

		// v0.12.5 Phase 6a: cluster-wide session lock via
		// pg_try_advisory_lock. Replaces SessionLockMap so two
		// concurrent continuations on the same session_id ACROSS
		// REPLICAS both get the 409 ErrSessionBusy.
		pgSessionLock := runner.NewPgSessionLocker(pgStore.Pool())
		srv.SetPgSessionLocker(pgSessionLock)

		// v0.12.5 Phase 6b: cluster-wide hook registry. Persists
		// registrations to the hooks table; backplane events keep
		// each replica's in-process cache current. The inner Registry
		// preserves the operator-yaml host-widen permit list (CLAUDE.md
		// rule #8 — frozen at boot, never DB-derived).
		innerReg := hooks.NewRegistryWithPermissions(cfg.Hooks.PermitHostWiden.Owners)
		dbReg, err := hooks.NewDBBackedRegistry(innerReg, pgStore, bp, cfg.Env.ReplicaID)
		if err != nil {
			log.Fatalf("coord: hook db registry init: %v", err)
		}
		if err := dbReg.LoadFromDB(bgCtx); err != nil {
			log.Printf("coord: hook db registry initial load: %v (continuing with empty cache)", err)
		}
		go dbReg.RunBackplaneConsumer(bgCtx)
		srv.SetHookRegistry(dbReg)

		log.Printf("coord: cluster mode active — replica_id=%s heartbeat=30s backplane=postgres-listen-notify user_quotas=db-backed cancel_ack_timeout=%s bus_fanout=on singleton_sweepers=on session_lock=pg-advisory hooks=db-backed", cfg.Env.ReplicaID, ackTimeout)
	}

	// Heartbeat sweeper — must run AFTER the cluster block so it can
	// pick up the advisoryLock (v0.12.4 Phase 5). In single-replica
	// mode advisoryLock stays nil and the sweeper's lock-gating branch
	// is bypassed — behavior identical to v0.11.x.
	if cfg.Env.HeartbeatSweeperEnabled && storeIface != nil {
		hbCfg := heartbeat.Config{
			Interval:   cfg.Env.HeartbeatSweepInterval,
			StaleAfter: cfg.Env.HeartbeatStaleAfter,
		}
		// Only assign the interface field when we have a non-nil
		// pointer — assigning a typed-nil *coord.AdvisoryLock to the
		// interface would yield a non-nil interface value whose
		// underlying pointer is nil, causing the sweeper's nil-check
		// to incorrectly think a lock was wired.
		if advisoryLock != nil {
			hbCfg.AdvisoryLock = advisoryLock
			hbCfg.AdvisoryLockKey = coord.LockKeyHeartbeatSweeper
		}
		sweeper := heartbeat.New(storeIface, hbCfg)
		go sweeper.Run(bgCtx)
	} else {
		log.Printf("heartbeat: sweeper disabled (LOOMCYCLE_HEARTBEAT_SWEEPER=0 or no Store)")
	}

	// Usage rollup-and-prune sweeper (RFC AV Phase 2b) — same placement
	// rationale as the heartbeat sweeper above: runs AFTER the cluster
	// block so it can pick up the advisoryLock. In single-replica mode
	// advisoryLock stays nil and the sweeper's lock-gating branch is
	// bypassed. Pruning is a compaction to daily usage_archive buckets,
	// not data loss — billing totals are preserved.
	// RFC BM Phase 2 reconciliation: when the retention sweeper is enabled AND its
	// effective (post-alias) chats mode is a delete-bearing one, the retention
	// sweeper owns aged-chat pruning — suppress the usage sweeper's legacy
	// aged-session archiver so a session is never cascade-deleted by both. The
	// usage-detail rollup is unaffected.
	// Only suppress the legacy usage archiver when the retention chats sweep will
	// ACTUALLY run — mirror retention's chatsPruneEnabled gate (export+prune needs
	// an ExportDir). Otherwise a misconfigured export+prune-without-dir would
	// disable BOTH paths, silently leaving aged chats unpruned.
	retentionChatsActive := cfg.Env.RetentionChatsMode == "prune" ||
		(cfg.Env.RetentionChatsMode == "export+prune" && cfg.Env.RetentionExportDir != "")
	retentionOwnsChats := cfg.Env.RetentionEnabled && retentionChatsActive
	if cfg.Env.UsageSweeperEnabled && storeIface != nil {
		uCfg := usage.Config{
			Interval:        cfg.Env.UsageSweepInterval,
			DetailRetention: cfg.Env.UsageDetailRetention,
			// RFC AV Phase 2b2 old-run archiver (opt-in; default OFF).
			RunRetention:       cfg.Env.UsageRunRetention,
			RunRetentionMode:   cfg.Env.UsageRunRetentionMode,
			ExportDir:          cfg.Env.UsageExportDir,
			DisableRunArchiver: retentionOwnsChats,
		}
		// Same typed-nil guard as the heartbeat block: only assign the
		// interface field from a non-nil pointer, or the sweeper's
		// nil-check would see a non-nil interface wrapping a nil pointer.
		if advisoryLock != nil {
			uCfg.AdvisoryLock = advisoryLock
			uCfg.AdvisoryLockKey = coord.LockKeyUsageSweeper
		}
		usageSweeper := usage.New(storeIface, uCfg)
		go usageSweeper.Run(bgCtx)
	} else {
		log.Printf("usage: sweeper disabled (LOOMCYCLE_USAGE_SWEEPER=0 or no Store)")
	}

	// RFC BM data-retention sweeper — same placement rationale as the usage
	// sweeper above (runs AFTER the cluster block so it can pick up the
	// advisoryLock). OPT-IN (default OFF): destructive (it deletes retired def
	// versions), unlike the lossless usage rollup. When the mode is
	// export+prune each version is written to the export dir before deletion.
	if cfg.Env.RetentionEnabled && storeIface != nil {
		rCfg := retention.Config{
			Interval:      cfg.Env.RetentionInterval,
			DefsMode:      cfg.Env.RetentionDefsMode,
			DefsMaxAge:    cfg.Env.RetentionDefsMaxAge,
			DefsKeepLastN: cfg.Env.RetentionDefsKeepLastN,
			// RFC BM Phase 2 aged-chat archiver (opt-in; default OFF, or inherited
			// from the legacy LOOMCYCLE_USAGE_RUN_RETENTION_* config in cfg.Load).
			ChatsMode:   cfg.Env.RetentionChatsMode,
			ChatsMaxAge: cfg.Env.RetentionChatsMaxAge,
			// The runtime's own maintenance chats age out faster, and are deleted
			// rather than exported. Same static-config resolution the consolidator
			// and History use, so all three agree on what "internal" means.
			ChatsInternalMaxAge: cfg.Env.RetentionChatsInternalMaxAge,
			InternalAgents:      cfg.InternalAgentNames(),
			// RFC BM Phase 3 retired-agent memory reclamation (opt-in; default OFF).
			MemMode:   cfg.Env.RetentionMemMode,
			MemMaxAge: cfg.Env.RetentionMemMaxAge,
			// RFC BL P4d class-aware memory-CONTENT prune (opt-in; default OFF).
			MemContentMode:   cfg.Env.RetentionMemContentMode,
			MemContentMaxAge: cfg.Env.RetentionMemContentMaxAge,
			ExportDir:        cfg.Env.RetentionExportDir,
		}
		// SQL-Memory scope reclamation needs the manager; nil (SQL Memory off) is
		// fine — the mem sweep then reclaims base memory + dirents only. Typed-nil
		// guard: only assign the interface field from a non-nil pointer.
		if sqlMemMgr != nil {
			rCfg.SQLMem = sqlMemMgr
			// The content prune needs the Document tool, which owns the chunk cascade —
			// the family stays off without it, so a deployment with no SQL Memory is
			// unaffected. Same typed-nil discipline as the interface assignments around it.
			if documentTool != nil {
				rCfg.ChunkPruner = documentTool
			}
		}
		// Same typed-nil guard as the usage block: only assign the interface field
		// from a non-nil pointer, or the sweeper's nil-check would see a non-nil
		// interface wrapping a nil pointer.
		if advisoryLock != nil {
			rCfg.AdvisoryLock = advisoryLock
			rCfg.AdvisoryLockKey = coord.LockKeyRetentionSweeper
		}
		retentionSweeper := retention.New(storeIface, rCfg)
		go retentionSweeper.Run(bgCtx)
	} else {
		log.Printf("retention: sweeper disabled (LOOMCYCLE_RETENTION_ENABLED != 1 or no Store)")
	}

	// RFC AW: seed the per-scope token-budget tracker from the RFC AV ledger
	// (month-to-date counters + the token_limits ceilings). Non-fatal — a
	// seeding fault leaves the counters empty (fail-open: budgets must never
	// block the runtime from starting). No-op when the server has no store.
	if err := srv.SeedLimits(bgCtx); err != nil {
		log.Printf("limits: seed failed (budgets start empty until next write): %v", err)
	}

	// Memory tool TTL sweeper. Cheap periodic DELETE of expired rows.
	// The store also filters expired entries at read time, so this
	// goroutine only matters for keeping the table small over the
	// long haul. v0.12.4: gated behind advisoryLock so only one
	// replica per cluster sweeps per tick.
	if cfg.Env.MemorySweepInterval > 0 && storeIface != nil {
		go runAdvisoryGatedSweeper(bgCtx, cfg.Env.MemorySweepInterval, advisoryLock, coord.LockKeyMemorySweeper, "memory",
			func(ctx context.Context) {
				swept, err := storeIface.MemorySweep(ctx)
				if err != nil {
					log.Printf("memory sweep: %v", err)
					return
				}
				if swept > 0 {
					log.Printf("memory sweep: deleted %d expired row(s)", swept)
				}
			})
		log.Printf("memory: sweeper interval=%s", cfg.Env.MemorySweepInterval)
	} else {
		log.Printf("memory: sweeper disabled (LOOMCYCLE_MEMORY_SWEEP_MS=0 or no Store)")
	}

	// Dead-link reconciliation: the only layer that actually COLLECTS chunk
	// references nothing can reach.
	//
	// The other two layers are incomplete by construction. delete_chunk cleans every
	// reference, but the body delete runs after the transaction commits because it
	// is a different store — so a crash in that window leaves an orphan. And the
	// read-time guard HIDES an orphan, which is right for a read and is exactly why
	// nothing ever notices one is there. An out-of-band delete bypasses both.
	//
	// On by default, unlike retention, because everything it removes is unreachable:
	// it restores an invariant rather than enacting a policy.
	if cfg.Env.DeadLinkGCInterval > 0 && storeIface != nil && sqlMemMgr != nil {
		docTool := &builtin.Document{Store: storeIface, SqlMem: sqlMemMgr}
		go runAdvisoryGatedSweeper(bgCtx, cfg.Env.DeadLinkGCInterval, advisoryLock, coord.LockKeyDeadLinkGC, "deadlink",
			func(ctx context.Context) {
				scopes, err := sqlMemMgr.ListScopes(ctx)
				if err != nil {
					log.Printf("deadlink gc: list scopes: %v", err)
					return
				}
				for _, sk := range scopes {
					// A run scope is dropped whole at run end, so reconciling it is
					// work with no beneficiary — and it has no durable body partition
					// to reconcile against.
					ms, ok := deadLinkMemoryScope(sk.Scope)
					if !ok {
						continue
					}
					rep, err := docTool.ReconcileDeadLinks(ctx, sk, ms, cfg.Env.DeadLinkGCDryRun)
					if err != nil {
						// Per-scope: one unreadable scope must not stop the sweep.
						log.Printf("deadlink gc: %s/%s/%s: %v", sk.Tenant, sk.Scope, sk.ScopeID, err)
						continue
					}
					if !rep.Empty() {
						verb := "collected"
						if cfg.Env.DeadLinkGCDryRun {
							verb = "would collect"
						}
						log.Printf("deadlink gc: %s %s", verb, rep)
					}
				}
			})
		log.Printf("deadlink gc: interval=%s dry_run=%v", cfg.Env.DeadLinkGCInterval, cfg.Env.DeadLinkGCDryRun)
	} else if storeIface != nil && sqlMemMgr == nil {
		log.Printf("deadlink gc: disabled (no SQL Memory — no chunk tables to reconcile)")
	} else {
		log.Printf("deadlink gc: disabled (LOOMCYCLE_DEADLINK_GC_MS=0 or no Store)")
	}

	// Channel tool TTL sweeper (v0.8.4). Same shape as MemorySweeper.
	if cfg.Env.ChannelsSweepInterval > 0 && storeIface != nil {
		go runAdvisoryGatedSweeper(bgCtx, cfg.Env.ChannelsSweepInterval, advisoryLock, coord.LockKeyChannelsSweeper, "channels",
			func(ctx context.Context) {
				swept, err := storeIface.ChannelSweepExpired(ctx)
				if err != nil {
					log.Printf("channels sweep: %v", err)
					return
				}
				if swept > 0 {
					log.Printf("channels sweep: deleted %d expired row(s)", swept)
				}
			})
		log.Printf("channels: sweeper interval=%s", cfg.Env.ChannelsSweepInterval)
	} else if storeIface != nil {
		log.Printf("channels: sweeper disabled (LOOMCYCLE_CHANNELS_SWEEP_MS=0)")
	}

	// v0.8.16 Interruption tool TTL sweeper. Marks pending rows
	// whose expires_at < now as timed_out.
	if cfg.Env.ChannelsSweepInterval > 0 && storeIface != nil {
		go runAdvisoryGatedSweeper(bgCtx, cfg.Env.ChannelsSweepInterval, advisoryLock, coord.LockKeyInterruptsSweeper, "interrupts",
			func(ctx context.Context) {
				swept, err := storeIface.InterruptSweepExpired(ctx)
				if err != nil {
					log.Printf("interrupts sweep: %v", err)
					return
				}
				if swept > 0 {
					log.Printf("interrupts sweep: timed_out %d expired row(s)", swept)
				}
			})
		log.Printf("interrupts: sweeper interval=%s", cfg.Env.ChannelsSweepInterval)
	}

	// v0.8.x process-resource metrics sampler. Default OFF;
	// operator opts in via LOOMCYCLE_METRICS_ENABLED=1. When
	// enabled, samples loomcycle's CPU + memory usage at
	// cfg.Env.MetricsSampleInterval cadence while at least one
	// agent run is active (the semaphore's Stats() is the idle
	// gate — no DB write, no /proc read while idle).
	// v0.8.17 PR 4: pause/resume/state HTTP endpoints. The Manager is
	// always constructed (cheap; one atomic + one channel) so the
	// endpoints respond rather than 503. Operator's default timeout
	// comes from cfg.Env.PauseDefaultTimeoutMs; 0 ⇒ pause.DefaultPauseTimeout.
	// pauseMgrRef stays nil unless storeIface is non-nil; the scheduler
	// branch below uses it to gate its tick on runtime state.
	var pauseMgrRef *pause.Manager
	if storeIface != nil {
		pauseDefault := time.Duration(cfg.Env.PauseDefaultTimeoutMs) * time.Millisecond
		pauseMgr := pause.NewManager(storeIface, pauseDefault)
		pauseMgrRef = pauseMgr
		srv.SetPauseManager(pauseMgr)
		if pauseDefault > 0 {
			log.Printf("pause: manager wired (default timeout=%s)", pauseDefault)
		} else {
			log.Printf("pause: manager wired (default timeout=%s)", pause.DefaultPauseTimeout)
		}

		// v0.12.3 Phase 4: cluster-wide pause/resume. The pause
		// manager's cluster paths read/write a singleton runtime_state
		// row + publish on `loomcycle.pause`. Only active in cluster
		// mode; single-replica mode keeps the v0.11.x in-process path
		// byte-identical.
		if cfg.Env.ReplicaID != "" {
			pgStoreForPause, ok := storeIface.(*storepostgres.Store)
			if !ok {
				log.Fatalf("coord: pause cluster wiring requires Postgres store (REPLICA_ID set + non-Postgres store — should have been caught by openStore)")
			}
			rss := coord.NewRuntimeStateStore(pgStoreForPause.Pool())
			cacheTTL := time.Duration(cfg.Env.PauseCacheTTLMs) * time.Millisecond
			pauseMgr.SetRuntimeStateStore(rss, cacheTTL)
			// Reuse the same Backplane the cluster block constructed.
			// We can't directly reference `bp` here because it's
			// scoped to the cluster block above; instead read it via
			// srv's existing wiring (SetCoord stashed it).
			// Backplane is interface-typed; type-assert through the
			// known-only path via a separate fetch.
			//
			// Simpler approach: rather than re-fetch, do the pause
			// cluster wiring INSIDE the cluster block where `bp` is
			// in scope. We can't because pauseMgr isn't constructed
			// yet at that point. Solution: declare a package-private
			// hook on Server to retrieve the backplane.
			if bp := srv.Backplane(); bp != nil {
				pauseMgr.SetBackplane(bp)
				if err := pauseMgr.SubscribeBackplane(bgCtx, bp); err != nil {
					log.Fatalf("coord: pause backplane subscribe: %v", err)
				}
				log.Printf("coord: cluster pause/resume wired (cache_ttl=%s)", cacheTTL)
			}
		}
	}

	// F42 / RFC X Phase 2: re-dispatch any pause_state='paused' runs left in
	// the store at boot (crash recovery / restart on a restored data dir) by
	// reconstructing each run's loop from its transcript. One-shot, run after
	// the server is fully wired (providers/tools/cancel/steer/pause). In a
	// cluster, gate behind an advisory lock so exactly ONE replica resurrects
	// each run; single-replica runs it directly. Backgrounded so a long-lived
	// resumed loop never blocks the rest of boot.
	if storeIface != nil {
		go func() {
			resume := func(ctx context.Context) error {
				n, warnings := srv.ResumePausedRuns(ctx)
				if n > 0 || len(warnings) > 0 {
					log.Printf("resume(boot): re-dispatched %d paused run(s); %d skipped/flagged", n, len(warnings))
				}
				return nil
			}
			if advisoryLock != nil {
				if _, err := advisoryLock.TryRun(bgCtx, coord.LockKeyResumePausedRuns, resume); err != nil {
					log.Printf("resume(boot): advisory-gated resume failed: %v", err)
				}
			} else {
				_ = resume(bgCtx)
			}
		}()
	}

	// RFC BL P1: reconcile the boot-time help-topic search index. The go:embed
	// help corpus is immutable in-process (no hot-reload), so this is one-shot at
	// boot. Content-hash gated — an unchanged corpus re-embeds nothing; only
	// changed/new sections are re-embedded and removed sections pruned. No-op
	// without an embedder or vector support (help search then degrades to an
	// in-memory substring scan). Backgrounded so a first-boot full embed never
	// blocks the rest of boot; advisory-gated in a cluster so exactly ONE replica
	// re-embeds. Non-fatal: on any error help search just degrades.
	if storeIface != nil && helpSet != nil {
		go func() {
			reconcile := func(ctx context.Context) error {
				stats, err := help.ReconcileIndex(ctx, helpSet, storeIface, embedder)
				if err != nil {
					log.Printf("help index: reconcile failed (search degrades): %v", err)
					return nil // non-fatal — degrade, don't fail boot
				}
				switch {
				case stats.Degraded:
					log.Printf("help index: no embedder/vector support — query uses in-memory substring scan")
				case stats.Written > 0 || stats.Pruned > 0 || stats.Failed > 0:
					log.Printf("help index: reconciled (%d written, %d pruned, %d unchanged, %d failed)", stats.Written, stats.Pruned, stats.Unchanged, stats.Failed)
				default:
					log.Printf("help index: up to date (%d sections, 0 embed calls)", stats.Unchanged)
				}
				return nil
			}
			if advisoryLock != nil {
				if _, err := advisoryLock.TryRun(bgCtx, coord.LockKeyHelpIndexReconcile, reconcile); err != nil {
					log.Printf("help index: advisory-gated reconcile failed: %v", err)
				}
			} else {
				_ = reconcile(bgCtx)
			}
		}()
	}

	if cfg.Env.MetricsEnabled && storeIface != nil {
		metricsSampler := metrics.New(storeIface, sem, metrics.Config{
			Interval:      cfg.Env.MetricsSampleInterval,
			CollectSystem: cfg.Env.MetricsCollectSystem,
			ReplicaID:     cfg.Env.ReplicaID,
		})
		go metricsSampler.Run(bgCtx)
		srv.SetMetricsSampler(metricsSampler)
		log.Printf("metrics: sampler enabled (interval=%s, system=%v)",
			cfg.Env.MetricsSampleInterval, cfg.Env.MetricsCollectSystem)
		if cfg.Env.MetricsSweepInterval > 0 && cfg.Env.MetricsRetentionDays > 0 {
			retentionDays := cfg.Env.MetricsRetentionDays
			go runAdvisoryGatedSweeper(bgCtx, cfg.Env.MetricsSweepInterval, advisoryLock, coord.LockKeyMetricsSweeper, "metrics",
				func(ctx context.Context) {
					cutoff := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour)
					swept, err := storeIface.MetricsSweep(ctx, cutoff)
					if err != nil {
						log.Printf("metrics sweep: %v", err)
						return
					}
					if swept > 0 {
						log.Printf("metrics sweep: deleted %d expired row(s)", swept)
					}
				})
			log.Printf("metrics: sweeper enabled (retention=%dd, interval=%s)",
				cfg.Env.MetricsRetentionDays, cfg.Env.MetricsSweepInterval)
		} else {
			log.Printf("metrics: sweeper disabled (retention=0 or sweep interval=0)")
		}
	} else if cfg.Env.MetricsEnabled {
		log.Printf("metrics: sampler disabled (no Store backend)")
	} else {
		log.Printf("metrics: sampler disabled (LOOMCYCLE_METRICS_ENABLED=0)")
	}

	// v1.x RFC E scheduler runtime. Default OFF; operator opts in via
	// LOOMCYCLE_SCHEDULER_ENABLED=1. Sweeper goroutine fires due
	// schedules from the substrate (schedule_run_state table). NIL
	// MCPCaller is passed because mcp.call hook dispatch is wired in
	// a follow-up PR — channel.publish + memory.set hooks work today.
	if cfg.Env.SchedulerEnabled && storeIface != nil && srv != nil {
		envAllowlist := make(map[string]bool, len(cfg.Env.SchedulerEnvAllowlist))
		for _, name := range cfg.Env.SchedulerEnvAllowlist {
			envAllowlist[name] = true
		}
		schedCfg := scheduler.Config{
			TickInterval: time.Duration(cfg.Env.SchedulerTickSeconds) * time.Second,
			FireTimeout:  time.Duration(cfg.Env.SchedulerFireTimeoutSeconds) * time.Second,
			EnvAllowlist: envAllowlist,
			// RFC BL P2 consolidation fan-out caps. Zero here means "use the
			// scheduler's documented defaults" (Config.defaults()), so an operator
			// who sets neither env var still gets 32 targets / 4 concurrent.
			MaxConsolidationTargets:     cfg.Env.MaxConsolidationTargets,
			MaxConsolidationConcurrency: cfg.Env.MaxConsolidationConcurrency,
			// The agents declared `internal:` — the fan-out neither reads their
			// sessions as evidence of new work nor hands them to a pass. Passed
			// as names because internal/scheduler takes no config dependency.
			InternalAgents: cfg.InternalAgentNames(),
		}
		// Materialize static yaml `scheduled_runs:` into the substrate so
		// they fire autonomously — symmetric with dynamically-created
		// schedules (which seed run_state on promoted create). The sweeper's
		// due-query is substrate-only, so without this a yaml-only schedule
		// never fires. Idempotent + fork-respecting; runs before the sweeper
		// so the first tick already sees the seeded rows. A minimal tool
		// instance suffices (bootstrap needs only Store + Cfg).
		bootSched := &builtin.ScheduleDef{Store: storeIface, Cfg: cfg}
		bootCtx := tools.WithRunIdentity(bgCtx, tools.RunIdentityValue{AgentID: "scheduler-bootstrap"})
		if n, err := bootSched.BootstrapStaticSchedules(bootCtx); err != nil {
			log.Printf("scheduler: static-schedule bootstrap: %v (continuing)", err)
		} else if n > 0 {
			log.Printf("scheduler: materialized %d static schedule(s) into the substrate", n)
		}

		// srv satisfies runner.Runner via its RunOnce method (the same
		// seam HTTP + gRPC drive interactive runs through). pauseMgr is
		// guaranteed non-nil in this branch (storeIface != nil ⇒ pause
		// manager constructed earlier in this function).
		sched := scheduler.New(schedCfg, storeIface, srv, pauseMgrRef, nil, log.Printf)
		// Resolve on_complete: channel.publish to the channel's DECLARED scope
		// (F37 / RFC T) so a hook on a scope:global channel lands at global,
		// not under the run's user scope. Must be set before Start.
		sched.SetChannelScope(srv.ResolveChannelScope)
		// RFC BL P2 consolidation fan-out: the provider resolver decides
		// parallel-vs-serial dispatch (a local model runtime is serialized), and
		// the advisory lock makes exactly one replica per tick enumerate the
		// targets — otherwise every replica would dispatch a full fan-out and
		// burn N× the tokens. advisoryLock is nil in single-replica mode, which
		// leaves the fan-out correctly unguarded; guard the assignment so a
		// typed-nil never reaches the non-nil interface check.
		sched.SetProviderResolver(srv)
		if advisoryLock != nil {
			sched.SetFanoutCoordination(advisoryLock, coord.MemoryConsolidatorLockKey)
		}
		sched.Start(bgCtx)
		log.Printf("scheduler: enabled (tick=%ds, fire_timeout=%ds, env_allowlist=%d names)",
			cfg.Env.SchedulerTickSeconds, cfg.Env.SchedulerFireTimeoutSeconds,
			len(cfg.Env.SchedulerEnvAllowlist))
	} else if cfg.Env.SchedulerEnabled {
		log.Printf("scheduler: disabled (no Store backend or no HTTP server)")
	} else {
		log.Printf("scheduler: disabled (LOOMCYCLE_SCHEDULER_ENABLED=0)")
	}

	// Boot summary of operator-declared channels. Mirrors the
	// "user_tiers: configured N — ..." line shape so operators see
	// the framework-primitive state at startup.
	if n := len(cfg.Channels); n > 0 {
		names := make([]string, 0, n)
		for name := range cfg.Channels {
			names = append(names, name)
		}
		sort.Strings(names)
		log.Printf("channels: configured %d — %s", n, strings.Join(names, " / "))
	}

	// Session-lock map GC. Defaults: prune entries idle ≥ 10 min, on
	// a 5-min tick. Disabled when both interval and max-idle resolve
	// to zero (operator can opt out by setting both to 0).
	sessionGCInterval := cfg.Env.SessionLockGCInterval
	if sessionGCInterval <= 0 {
		sessionGCInterval = 5 * time.Minute
	}
	sessionGCMaxIdle := cfg.Env.SessionLockMaxIdle
	if sessionGCMaxIdle <= 0 {
		sessionGCMaxIdle = 10 * time.Minute
	}
	go srv.RunSessionLockGC(bgCtx, sessionGCInterval, sessionGCMaxIdle)
	// RFC BK: idle-reap resident interactive sub-agents (per-replica; in-process
	// registry, no cluster coordination needed).
	go srv.RunResidentSweeper(bgCtx)

	// v0.8.15: LoomCycle MCP — when launched via `loomcycle mcp --config Y`,
	// expose the runtime as an MCP server over stdio alongside the HTTP
	// listener. Per the RFC, both surfaces run together; the HTTP server
	// keeps serving /healthz, /v1/*, and the Web UI while MCP clients
	// (Claude Code first) drive the same business logic through
	// Connector dispatch.
	//
	// The dynamic-agent TTL sweeper runs in BOTH default and mcp modes:
	// the dynamic_agents table is shared. operators who never use the
	// `register_agent` MCP tool simply have an empty table the sweeper
	// passes over with zero rows deleted.
	if cfg.Env.DynamicAgentSweepInterval > 0 {
		go runAdvisoryGatedSweeper(bgCtx, cfg.Env.DynamicAgentSweepInterval, advisoryLock, coord.LockKeyDynamicAgentSweeper, "dynamic_agents",
			func(ctx context.Context) {
				n, err := storeIface.DynamicAgentSweep(ctx)
				if err != nil {
					log.Printf("dynamic_agents sweep: %v", err)
					return
				}
				if n > 0 {
					log.Printf("dynamic_agents sweep: deleted %d expired row(s)", n)
				}
			})
		log.Printf("dynamic_agents: sweeper enabled (interval=%s)", cfg.Env.DynamicAgentSweepInterval)
	}

	// RFC AH Phase 2b ephemeral-volume crash-recovery sweeper. The inline
	// run-completion purge is the primary path; this backstops a host that
	// crashed before the inline purge ran. Each tick lists (root_run_id,
	// tenant_id) of TERMINAL-and-NOT-paused runs that still own ephemeral
	// rows, then runs the SAME fenced RemoveAll the inline purge uses +
	// deletes the rows. Cluster-gated so one replica per tick does the work.
	// Needs a dynamic_root to have anything to clean (no root → no ephemeral
	// volumes); skipped otherwise.
	if dynRoot, hasDynRoot := builtin.DynamicVolumeRoot(cfg); cfg.Env.EphemeralVolumeSweepInterval > 0 && storeIface != nil && hasDynRoot {
		go runAdvisoryGatedSweeper(bgCtx, cfg.Env.EphemeralVolumeSweepInterval, advisoryLock, coord.LockKeyEphemeralVolumeSweeper, "ephemeral_volumes",
			func(ctx context.Context) {
				cands, err := storeIface.EphemeralVolumeSweepCandidates(ctx)
				if err != nil {
					log.Printf("ephemeral_volumes sweep: candidates: %v", err)
					return
				}
				purged := 0
				for _, c := range cands {
					// Fenced RemoveAll of the per-run subtree (re-derived;
					// never a stored path). removed=false → already gone.
					if _, perr := builtin.PurgeEphemeralRunTree(dynRoot, c.RootRunID, "ephemeral sweeper"); perr != nil {
						log.Printf("ephemeral_volumes sweep: purge run=%s: %v", c.RootRunID, perr)
						// Still drop the rows below — a purge fault must not
						// wedge the row forever (the dir may be partly gone).
					}
					if _, derr := storeIface.EphemeralVolumeDeleteByRun(ctx, c.RootRunID); derr != nil {
						log.Printf("ephemeral_volumes sweep: delete rows run=%s: %v", c.RootRunID, derr)
						continue
					}
					purged++
				}
				if purged > 0 {
					log.Printf("ephemeral_volumes sweep: purged %d terminal run tree(s)", purged)
				}
			})
		log.Printf("ephemeral_volumes: sweeper enabled (interval=%s, dynamic_root=%s)", cfg.Env.EphemeralVolumeSweepInterval, dynRoot)
	}
	// Optional operator default for the spawn_run transport timeout
	// (RFC P). 0/unset → disabled (defer to the run's own budget).
	// Read outside the mcpMode block: the HTTP MCP transport below is
	// always wired (both server and mcp modes) and the RFC R thin client
	// (`mcp --upstream`) proxies to it, so it needs the same bound.
	mcpSpawnRunTimeoutMS := 0
	if v := os.Getenv("LOOMCYCLE_MCP_SPAWN_RUN_TIMEOUT_MS"); v != "" {
		if n, perr := strconv.Atoi(v); perr == nil && n > 0 {
			mcpSpawnRunTimeoutMS = n
		}
	}
	if mcpMode {
		// Optional operator override for the stdio dispatch concurrency
		// cap (RFC O). 0/unset → the package default (16).
		mcpMaxConcurrentCalls := 0
		if v := os.Getenv("LOOMCYCLE_MCP_MAX_CONCURRENT_CALLS"); v != "" {
			if n, perr := strconv.Atoi(v); perr == nil && n > 0 {
				mcpMaxConcurrentCalls = n
			}
		}
		mcpSrv := lcmcp.New(lcmcp.Config{
			Connector:          srv, // *http.Server satisfies connector.Connector
			Runner:             srv, // *http.Server satisfies runner.Runner (streaming)
			Store:              storeIface,
			Logf:               log.Printf, // log goes to stderr; never stdout (stdout is the JSON-RPC wire)
			ServerName:         "loomcycle",
			ServerVersion:      buildVersion,
			MaxConcurrentCalls: mcpMaxConcurrentCalls,
			SpawnRunTimeoutMS:  mcpSpawnRunTimeoutMS,
		})
		// Run the MCP stdio loop in a goroutine. When stdin closes
		// (Claude Code disconnects), Serve returns; we log and let
		// the HTTP listener keep serving until the process is sent
		// a signal. Operators who want MCP-disconnect = process-exit
		// can wrap loomcycle in a supervisor that watches for the
		// log line.
		go func() {
			// Sourced from the registry (lcmcp.MetaToolCount) so the count
			// can't drift — the hardcoded "20" went stale once the registry
			// grew past 40. Same rationale as the CLI help count.
			log.Printf("mcp: stdio server starting (%d tools registered)", lcmcp.MetaToolCount())
			if err := mcpSrv.Serve(bgCtx, os.Stdin, os.Stdout); err != nil {
				log.Printf("mcp: serve ended: %v", err)
			} else {
				log.Printf("mcp: stdio closed; HTTP listener continues")
			}
		}()
	}

	// v0.8.15.3: HTTP MCP transport. Always wired (both server and
	// mcp modes), per RFC decision C-open-question rationale:
	// service-to-service consumers don't use the stdio wrapper and
	// need a network endpoint. Security posture is identical to all
	// other /v1/* endpoints — bearer-authed via s.authMiddleware in
	// the server's Mux(). This /v1/_mcp endpoint is what the RFC R
	// thin client (`loomcycle mcp --upstream`) proxies to.
	mcpHTTPHandler := lcmcp.NewHTTPHandler(lcmcp.Config{
		Connector:     srv, // same *http.Server instance used by stdio MCP
		Runner:        srv,
		Store:         storeIface,
		Logf:          log.Printf,
		ServerName:    "loomcycle",
		ServerVersion: buildVersion,
		// RFC P spawn_run timeout must apply to the HTTP transport too —
		// the RFC R thin client (`mcp --upstream`) proxies to /v1/_mcp, so
		// without this the now-recommended topology would have no spawn_run
		// bound (the exp3 wedge class, one layer up). MaxConcurrentCalls is
		// intentionally NOT passed: it builds the stdio Serve loop's
		// semaphore and is meaningless here — the HTTP transport is already
		// per-request concurrent at the http.Server level, and run
		// concurrency is bounded downstream by the runtime semaphore +
		// per-tenant fairness.
		SpawnRunTimeoutMS: mcpSpawnRunTimeoutMS,
	})
	srv.SetMCPHTTPHandler(mcpHTTPHandler)
	// Background session sweeper. Reuses the dynamic-agent sweeper
	// pattern: bound to bgCtx, exits on shutdown, idempotent under
	// concurrent invocations. 5-minute cadence is well under the
	// 30-min session inactivity TTL — leaves at least ~25 min between
	// a session's last access and its earliest possible reaping.
	lcmcp.RunHTTPSessionSweeper(bgCtx, mcpHTTPHandler.Sessions(), 5*time.Minute, log.Printf)
	log.Printf("mcp: HTTP transport enabled at POST /v1/_mcp (session sweeper interval=5m, TTL=30m)")

	log.Printf("session-lock GC: interval=%s max_idle=%s", sessionGCInterval, sessionGCMaxIdle)

	// Periodic probe loop: re-runs the resolver's reachability +
	// model-list probe on the configured cadence so a provider
	// recovering from an outage shows up in the matrix without a
	// loomcycle restart. Default 15 minutes; clamped to [60s, 1h] in
	// the config loader. The first probe already ran inside
	// buildResolver above (synchronously, before traffic begins) —
	// this goroutine handles all subsequent rounds.
	probeInterval := cfg.Env.ResolveProbeInterval
	if probeInterval <= 0 {
		probeInterval = 15 * time.Minute
	}
	// v0.8.17: register the force-probe callback. The snapshot restore
	// handler (POST /v1/_snapshots/{id}/restore) calls
	// resolver.ForceProbe(ctx) to refresh the matrix before the
	// operator can call Resume — the pause-resume-snapshot RFC says
	// the resolver state is excluded from snapshots, so a fresh probe
	// closes the gap.
	resolver.SetForceProbeCallback(func(ctx context.Context) {
		runResolveProbeOnce(ctx, resolver, pr, cfg)
	})
	go runResolveProbeLoop(bgCtx, resolver, pr, cfg, probeInterval)
	log.Printf("resolve probe: interval=%s", probeInterval)

	go func() {
		log.Printf("loomcycle listening on %s", cfg.Env.ListenAddr)
		if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("listen: %v", err)
		}
	}()

	// gRPC server. Optional; opt-in via LOOMCYCLE_GRPC_ADDR. Reuses
	// the same Store + cancel registry as the HTTP server so both
	// surfaces give identical answers. PR 1 of v0.5.5 implements
	// metadata RPCs only (Run/Continue stub to Unimplemented); PR 2
	// wires the streaming surface.
	var grpcSrv *googlegrpc.Server
	if cfg.Env.GrpcAddr != "" {
		grpcAdapter := loomgrpc.New(loomgrpc.Config{
			Store:     storeIface,
			CancelReg: srv.CancelRegistry(),
			Connector: srv, // v0.8.15: *http.Server satisfies connector.Connector
			Runner:    srv, // *http.Server satisfies runner.Runner (streaming)
			// RFC AW: share the HTTP server's token-budget tracker so the gRPC
			// TokenLimit RPC reads the identical live counters + reloads the same
			// cache the /v1/_limits handler uses.
			Limits:    srv.LimitsTracker(),
			AuthToken: cfg.Env.AuthToken,
			// RFC L: reuse the HTTP server's token resolution so gRPC
			// stamps the same authoritative principal (gRPC runs flow
			// authoritatively through RunOnce) and shares the open-mode
			// decision.
			PrincipalResolver: srv.ResolvePrincipal,
			AuthConfigured:    srv.AuthConfigured,
			BuildCommit:       buildCommit,
			BuildTime:         buildTime,
		})
		grpcSrv = googlegrpc.NewServer(
			googlegrpc.UnaryInterceptor(grpcAdapter.UnaryAuthInterceptor()),
			googlegrpc.StreamInterceptor(grpcAdapter.StreamAuthInterceptor()),
		)
		loomcyclepb.RegisterLoomcycleServer(grpcSrv, grpcAdapter)
		// v1.x RFC G — register the A2A gRPC binding on the same server.
		// gRPC is not a path-mounted http.Handler (it needs the HTTP/2
		// server), so the /a2a/grpc binding rides loomcycle's existing
		// grpc.Server; the AgentCard advertises the gRPC port.
		//
		// Call-site nil-guard mirrors the other two a2aServer uses above
		// (SetExtraMux at ~1426, PathTenantWrapper at ~1497): a2aServer is
		// nil whenever A2AServerEnabled is off. RegisterGRPC is itself
		// nil-receiver-safe, so this is defensive consistency rather than a
		// live-crash fix — it stops the gRPC path depending on the callee's
		// internal guard (exp7 C3).
		if a2aServer != nil {
			a2aServer.RegisterGRPC(grpcSrv)
		}
		grpcLis, err := net.Listen("tcp", cfg.Env.GrpcAddr)
		if err != nil {
			log.Fatalf("grpc listen %s: %v", cfg.Env.GrpcAddr, err)
		}
		grpcAdapter.MustLogStartupBanner(cfg.Env.GrpcAddr)
		go func() {
			// GracefulStop returns ErrServerStopped on the
			// normal shutdown path — don't log it as a serve
			// failure (would pollute operator logs and trip alert
			// rules watching for "error" tokens).
			if err := grpcSrv.Serve(grpcLis); err != nil && !errors.Is(err, googlegrpc.ErrServerStopped) {
				log.Printf("grpc serve: %v", err)
			}
		}()
	}

	// Graceful shutdown.
	sig := make(chan os.Signal, 1)
	signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
	<-sig
	log.Println("shutting down…")
	bgCancel() // tear down sweeper + GC goroutines first
	if heartbeatRunner != nil {
		heartbeatRunner.Stop()
	}
	// v0.11.9 — stop the OAuth-dev refresher's background goroutine
	// before the process exits so an in-flight refresh HTTP call
	// either completes + persists OR cleanly aborts. Without this,
	// the OS hard-kills the goroutine and a partially-rotated token
	// could be lost (the on-disk token is atomic write, but the
	// fresh access token might never make it to the persist step).
	if pr.anthropicOAuthRefresher != nil {
		pr.anthropicOAuthRefresher.Stop()
	}
	if grpcSrv != nil {
		grpcSrv.GracefulStop()
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	_ = httpServer.Shutdown(ctx)
	// RFC BL: stop the access-count flusher and flush the final window AFTER the
	// HTTP+gRPC servers have finished draining. In-flight Search requests
	// Record() access bumps right up to the end of that drain window, so the
	// flusher's final flush must run last to capture them — stopping it earlier
	// would leave those late bumps with no goroutine left to flush them. The
	// store closer is deferred (runs after main returns), so the store is still
	// open for this final write.
	accessFlusher.Stop()
	// Flush OTEL spans before exit. The OTLP exporter batches; without this,
	// in-flight spans never reach the collector. Give it its OWN 5s budget —
	// sharing the HTTP shutdown's ctx meant a slow in-flight request drain
	// could exhaust the deadline first, silently dropping the final spans.
	if otelShutdown != nil {
		otelCtx, otelCancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer otelCancel()
		if err := otelShutdown(otelCtx); err != nil {
			log.Printf("otel: shutdown failed: %v", err)
		}
	}
}

// openStore resolves the operator's storage choice (sqlite default;
// postgres opt-in via storage.backend or LOOMCYCLE_STORAGE_BACKEND) and
// returns a ready Store + a Close-and-log closer.
//
// Errors propagate up to log.Fatalf in main; they cover both
// missing-config (postgres backend selected without a DSN) and
// dial/migration failures (postgres unreachable, schema not initialised
// when LOOMCYCLE_PG_AUTOMIGRATE=0).
// warnLegacyTenantMemory says loudly, once at boot, when memory rows are
// stranded at the legacy "" tenant.
//
// RFC BL added tenant_id to the `memory` table without backfilling the rows
// already in it, so every pre-upgrade row stayed at "" while reads became
// tenant-scoped. The failure is quiet in a specific and misleading way: Document
// keeps chunk BODIES in that table but chunk STRUCTURE in SQL Memory, which is
// not partitioned the same way, so an affected document still lists, still
// opens, and still exports as a complete heading tree with every section empty.
// It reads as corruption rather than as an access problem, which is exactly why
// it needs naming at boot rather than leaving an operator to discover it.
//
// Only the MIXED state warns. A deployment holding nothing but "" rows is fine —
// its reads go to "" too, which is the legacy/open-mode posture — so gating on
// the presence of real tenants keeps this quiet on every unaffected install.
// Best-effort: a failed probe must never block boot.
func warnLegacyTenantMemory(st store.Store) {
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	legacy, tenants, err := st.MemoryLegacyTenantStats(ctx)
	if err != nil || legacy == 0 || len(tenants) == 0 {
		return
	}
	target := ""
	if len(tenants) == 1 {
		// The overwhelmingly common shape, and the one where we can name the
		// exact call rather than describing it.
		target = tenants[0]
	}
	msg := fmt.Sprintf("memory: WARNING — %d memory row(s) are stranded at the legacy \"\" tenant and are UNREADABLE from a tenant-scoped session (tenants holding rows: %s). "+
		"RFC BL added the tenant_id column without backfilling pre-existing rows. Symptoms: a Document created before the upgrade lists and exports as headings with EMPTY bodies (its structure lives in SQL Memory, which is partitioned differently), and an agent's pre-upgrade key/value memory reads as absent. "+
		"Nothing is deleted. Fix: POST /v1/_memory/repair-tenant (substrate:admin) — it reports by default, and applies with {\"dry_run\":false}; the Settings hub exposes it as Maintenance for shell-less deployments.",
		legacy, strings.Join(tenants, ", "))
	if target != "" {
		msg += fmt.Sprintf(" For this deployment: {\"tenant\":%q,\"dry_run\":false}.", target)
	}
	log.Print(msg)
}

func openStore(cfg *config.Config) (store.Store, func(), error) {
	switch cfg.Storage.Backend {
	case "sqlite", "":
		if cfg.Env.ReplicaID != "" {
			return nil, nil, errors.New(
				"LOOMCYCLE_REPLICA_ID is set but storage.backend is sqlite: " +
					"multi-replica requires Postgres (LISTEN/NOTIFY backplane + " +
					"shared replicas heartbeat table). Set storage.backend: postgres " +
					"+ storage.pg_dsn, or unset LOOMCYCLE_REPLICA_ID for single-replica mode")
		}
		if err := os.MkdirAll(cfg.Env.DataDir, 0o755); err != nil {
			return nil, nil, fmt.Errorf("data dir: %w", err)
		}
		dbPath := filepath.Join(cfg.Env.DataDir, "loomcycle.db")
		st, err := storesqlite.Open(dbPath)
		if err != nil {
			return nil, nil, fmt.Errorf("sqlite open: %w", err)
		}
		st.SetChannelDebug(channelDebugEnabled())
		log.Printf("store: sqlite at %s", dbPath)
		warnLegacyTenantMemory(st)
		return st, func() { _ = st.Close() }, nil

	case "postgres":
		if cfg.Storage.PgDSN == "" {
			return nil, nil, fmt.Errorf("postgres backend selected but storage.pg_dsn / LOOMCYCLE_PG_DSN is empty")
		}
		st, err := storepostgres.Open(context.Background(), storepostgres.Config{
			DSN:             cfg.Storage.PgDSN,
			MaxOpenConns:    cfg.Storage.PgMaxOpenConns,
			MinIdleConns:    cfg.Storage.PgMinIdleConns,
			AutoMigrate:     cfg.Storage.PgAutoMigrate,
			PgvectorEnabled: cfg.Env.PgvectorEnabled,
			ChannelDebug:    channelDebugEnabled(),
		})
		if err != nil {
			return nil, nil, fmt.Errorf("postgres open: %w", err)
		}
		log.Printf("store: postgres (automigrate=%v pgvector=%v)", cfg.Storage.PgAutoMigrate, cfg.Env.PgvectorEnabled)
		warnLegacyTenantMemory(st)
		return st, func() { _ = st.Close() }, nil

	default:
		return nil, nil, fmt.Errorf("unknown storage.backend %q (want \"sqlite\" or \"postgres\")", cfg.Storage.Backend)
	}
}

// providerResolver holds the constructed Provider instances keyed by the id
// agents reference in `provider:` (RFC BF P2a). newProviderResolver builds byID
// from cfg.Providers via the driver registry (providers.NewDriver) — a
// config-driven flip of the pre-P2a hardcoded per-provider fields. It is
// byte-identical when no operator `providers:` block is declared, because the
// embedded default-providers layer (cmd/loomcycle/embedded/providers.default.yaml)
// supplies the built-ins. A provider whose enabling env is absent is simply not
// in byID; Get reproduces the pre-P2a "not configured" error for it.
type providerResolver struct {
	byID map[string]providers.Provider
	// cfg is retained so Get can tell a declared-but-disabled provider (a
	// bespoke "not configured" error) apart from a truly unknown id.
	cfg *config.Config
	// anthropic-oauth-dev remains a residual hardcoded path: its Refresher
	// lifecycle is main-owned (Start here, Stop at shutdown). The provider goes
	// into byID like any other; this field only carries the refresher handle for
	// teardown. See newProviderResolver.
	anthropicOAuthRefresher *anthropic_oauth_dev.Refresher
}

// newProviderResolver builds every enabled provider from cfg.Providers via the
// RFC BF driver registry (config-driven flip of the pre-P2a hardcoded per-provider
// construction). It is byte-identical when no operator `providers:` block is
// declared, because the embedded default-providers layer supplies the built-ins
// and providerbuild.DriverOptions/providerEnabled port the exact env behaviour. An
// unconstructable provider (bad driver/dialect/base_url) is a boot error.
func newProviderResolver(cfg *config.Config) (*providerResolver, error) {
	pr := &providerResolver{byID: map[string]providers.Provider{}, cfg: cfg}

	for id, pc := range cfg.Providers {
		if !providerEnabled(id, pc, cfg) {
			continue
		}
		p, err := providers.NewDriver(pc.Driver, providerbuild.DriverOptions(id, pc, cfg))
		if err != nil {
			return nil, fmt.Errorf("provider %q: %w", id, err)
		}
		pr.byID[id] = p
	}

	// Residual per-provider boot diagnostics (operator-facing knob echoes, NOT
	// resolution logic) — kept id-keyed so the boot log is unchanged from pre-P2a
	// for the mock/code-js paths. anthropic-oauth-dev logs its own below.
	if _, ok := pr.byID["mock"]; ok {
		log.Printf("mock provider: enabled (LATENCY_MS=%q LATENCY_JITTER_MS=%q 429_RATE=%q 500_RATE=%q)",
			os.Getenv("LOOMCYCLE_MOCK_LATENCY_MS"),
			os.Getenv("LOOMCYCLE_MOCK_LATENCY_JITTER_MS"),
			os.Getenv("LOOMCYCLE_MOCK_429_RATE"),
			os.Getenv("LOOMCYCLE_MOCK_500_RATE"))
	}
	if _, ok := pr.byID["mock-stable"]; ok {
		log.Printf("mock-stable provider: enabled (failure injection always off; fallback target for [mock, mock-stable] tier policies)")
	}
	if _, ok := pr.byID["code-js"]; ok {
		log.Printf("code-js provider: enabled (root=%q deterministic=%v run_timeout=%s abi=%s)",
			cfg.Env.CodeAgentsRoot, cfg.Env.CodeAgentsDeterministic, cfg.Env.CodeAgentsRunTimeout, codejs.ABIVersion)
	}

	// v0.11.9 — anthropic-oauth-dev stays a residual hardcoded path (main-owned
	// Refresher lifecycle; not a registry driver). Two gates: env var + tokens on
	// disk. The provider (when live) joins byID like any other; the refresher
	// handle is kept for shutdown teardown.
	if os.Getenv(anthropic_oauth_dev.EnvEnabled) == "1" {
		streamOpts := streamhttp.Options{
			HeaderTimeout: cfg.Env.ProviderHeaderTimeout,
			IdleTimeout:   cfg.Env.ProviderIdleTimeout,
		}
		storePath, pathErr := anthropic_oauth_dev.DefaultTokenStorePath()
		switch {
		case pathErr != nil:
			log.Printf("anthropic-oauth-dev: not registered — cannot resolve config dir: %v", pathErr)
		default:
			store := anthropic_oauth_dev.NewTokenStore(storePath)
			if _, loadErr := store.Load(); loadErr != nil {
				log.Printf("anthropic-oauth-dev: env var set but no tokens at %s — run `loomcycle anthropic login` to authorize", storePath)
			} else {
				refresher := anthropic_oauth_dev.NewRefresher(store, anthropic_oauth_dev.ExchangeOptions{}, log.Printf)
				refresher.Start(context.Background())
				pr.anthropicOAuthRefresher = refresher
				pr.byID["anthropic-oauth-dev"] = anthropic_oauth_dev.New(
					refresher, streamOpts, anthropic_oauth_dev.ResolveClaudeCodeVersion(), nil,
				)
				log.Printf("anthropic-oauth-dev: registered (token expires %s; user-agent=claude-cli/%s)",
					refresher.Token().ExpiresAt.Format(time.RFC3339),
					anthropic_oauth_dev.ResolveClaudeCodeVersion())
				log.Printf("anthropic-oauth-dev: WARNING — reverse-engineered OAuth flow (Pi reference + loomcycle replication); NOT officially endorsed by Anthropic; subscription terms historically restrict programmatic use; operator carries all risk including account flag/revocation; no warranty/SLA/liability from loomcycle; see docs/PROVIDERS.md")
			}
		}
	}

	logProviderSummary(pr, cfg)
	return pr, nil
}

// providerEnabled reproduces the exact pre-P2a env gates: a keyed provider needs
// its api_key_env set; ollama-local needs a base URL that isn't the "disabled"
// opt-out; mock/mock-stable need LOOMCYCLE_MOCK_ENABLED=1; code-js needs
// CodeAgentsEnabled. anthropic-oauth-dev is intentionally NOT handled here — it is
// the residual hardcoded path in newProviderResolver.
func providerEnabled(id string, pc config.ProviderConfig, cfg *config.Config) bool {
	switch id {
	case "ollama-local":
		// The loader defaults OLLAMA_BASE_URL to http://localhost:11434; the opt-out
		// is the "disabled" sentinel. Read the RESOLVED value (cfg.Env), not
		// os.Getenv, so the getenvDefault behaviour is preserved exactly.
		return cfg.Env.OllamaBaseURL != "" && cfg.Env.OllamaBaseURL != "disabled"
	case "mock", "mock-stable":
		return os.Getenv("LOOMCYCLE_MOCK_ENABLED") == "1"
	case "code-js":
		return cfg.Env.CodeAgentsEnabled
	default:
		// Keyed provider (anthropic/openai/deepseek/gemini/ollama + any declared
		// 3rd-party naming an api_key_env): enabled iff that env var is set — the
		// exact pre-RFC-BF gate for the built-ins.
		if pc.APIKeyEnv != "" {
			return os.Getenv(pc.APIKeyEnv) != ""
		}
		// Keyless declared provider — a self-hosted OpenAI-compatible endpoint
		// with no auth (vLLM/llama.cpp/a second Ollama behind base_url), the RFC BF
		// headline case. Declaring it in `providers:` IS the opt-in, so it is
		// enabled; the driver calls base_url with no key. Without this a keyless
		// 3rd-party provider was silently treated as "not configured" and could
		// never be used. No built-in reaches here with an empty api_key_env
		// (ollama-local/mock/code-js are special-cased above), so this only newly
		// enables an operator-declared keyless provider.
		return true
	}
}

// newProviderGates builds the RFC BF P2b per-provider concurrency gates from
// cfg.Providers: one gate per provider whose max_concurrent > 0 (the rest are
// uncapped → a noop at admission). Shared queue depth + timeout come from the
// concurrency block (LOOMCYCLE_PROVIDER_QUEUE_DEPTH / _TIMEOUT_MS, defaulting to
// the global MaxQueueDepth / QueueTimeout). Returns empty gates — hence zero
// admission overhead — when no provider sets a cap (the default deployment).
func newProviderGates(cfg *config.Config) *concurrency.ProviderGates {
	caps := make(map[string]int)
	for id, pc := range cfg.Providers {
		if pc.MaxConcurrent > 0 {
			caps[id] = pc.MaxConcurrent
		}
	}
	depth := cfg.Concurrency.ProviderQueueDepthOrDefault()
	timeout := cfg.Concurrency.ProviderQueueTimeout()
	gates := concurrency.NewProviderGates(caps, depth, timeout)
	if len(caps) > 0 {
		ids := make([]string, 0, len(caps))
		for id := range caps {
			ids = append(ids, id)
		}
		sort.Strings(ids) // deterministic log order
		var b strings.Builder
		for i, id := range ids {
			if i > 0 {
				b.WriteString(", ")
			}
			fmt.Fprintf(&b, "%s=%d", id, caps[id])
		}
		log.Printf("providers: per-provider concurrency caps enabled — %s (queue_depth=%d timeout=%s)",
			b.String(), depth, timeout)
	}
	return gates
}

// notConfiguredError reproduces the pre-P2a "not configured" error for a
// declared-but-disabled provider so callers (and the probe) see the exact same
// message they did before the flip. Built-in ids keep their bespoke text; a
// declared 3rd-party id gets the generic "(set <api_key_env>)" form.
func notConfiguredError(id string, cfg *config.Config) error {
	switch id {
	case "ollama":
		return fmt.Errorf("ollama provider not configured (set OLLAMA_API_KEY for hosted ollama.com; use provider id \"ollama-local\" for a local-network Ollama)")
	case "ollama-local":
		return fmt.Errorf("ollama-local provider not configured (set OLLAMA_BASE_URL, or it's been opted out via OLLAMA_BASE_URL=disabled)")
	case "mock":
		return fmt.Errorf("mock provider not configured (set LOOMCYCLE_MOCK_ENABLED=1)")
	case "mock-stable":
		return fmt.Errorf("mock-stable provider not configured (set LOOMCYCLE_MOCK_ENABLED=1)")
	case "code-js":
		return fmt.Errorf("code-js provider not configured: code agents are disabled (set LOOMCYCLE_CODE_AGENTS_ENABLED=1)")
	case "anthropic-oauth-dev":
		return fmt.Errorf("anthropic-oauth-dev not registered (set LOOMCYCLE_ANTHROPIC_OAUTH_DEV_ENABLED=1 + run `loomcycle anthropic login`)")
	default:
		env := cfg.Providers[id].APIKeyEnv
		if env == "" {
			return fmt.Errorf("%s provider not configured", id)
		}
		return fmt.Errorf("%s provider not configured (set %s)", id, env)
	}
}

// providerDisabledReason is the probe's short exclusion reason for a
// declared-but-disabled provider, byte-identical to the pre-P2a jobs slice.
func providerDisabledReason(id string, pc config.ProviderConfig) string {
	switch id {
	case "ollama-local":
		return "OLLAMA_BASE_URL not configured"
	case "mock", "mock-stable":
		return "LOOMCYCLE_MOCK_ENABLED not set"
	default:
		return fmt.Sprintf("%s not set", pc.APIKeyEnv)
	}
}

// logProviderSummary logs the config-sourced provider inventory at boot. Under
// RFC BF providers are fully config-driven; the embedded default-providers layer
// (cmd/loomcycle/embedded/providers.default.yaml) supplies the built-ins.
func logProviderSummary(pr *providerResolver, cfg *config.Config) {
	enabled := make([]string, 0, len(pr.byID))
	for id := range pr.byID {
		enabled = append(enabled, id)
	}
	sort.Strings(enabled)
	log.Printf("providers: %d configured, %d enabled (%s)", len(cfg.Providers), len(pr.byID), strings.Join(enabled, ", "))
}

// validateCodeAgents fails loud at startup for any statically-configured
// `provider: code-js` agent whose JS is missing or won't parse (RFC J
// Deliverable 3) — so a broken code-agent fails the boot, NOT the first
// scheduled fire. Two failure modes:
//   - code-js used but disabled → log.Fatalf pointing at the enable flag.
//   - code-js enabled but JS broken/missing → log.Fatalf naming the agent + path.
//
// Dynamic AgentDefs (created at runtime via the AgentDef tool) are not seen
// here; their JS validates on first Call and surfaces as an EventError.
func validateCodeAgents(cfg *config.Config, pr *providerResolver) {
	// nil interface → (nil,false): cp stays nil when code-js is disabled, matching
	// the pre-P2a pr.codeJS==nil path.
	cp, _ := pr.byID["code-js"].(*codejs.Provider)
	var broken []string
	for name, def := range cfg.Agents {
		if def.Provider != "code-js" {
			continue
		}
		if cp == nil {
			log.Fatalf("agent %q uses `provider: code-js` but code agents are disabled — set LOOMCYCLE_CODE_AGENTS_ENABLED=1", name)
		}
		// An inline `code:` body has no filesystem file — validate the body
		// itself (mirrors the run-time provider, which prefers the inline body
		// over agent_code/<name>/index.js). Only fall back to the filesystem
		// compile when no inline body is declared. Without this, a yaml code
		// agent shipped with an inline body and no host index.js — the whole
		// point of inline ingestion (no FS bind) — would fail the boot.
		var err error
		if def.Code != "" {
			if _, verr := codejs.Validate(def.Code); verr != nil {
				err = fmt.Errorf("code-agent %q: inline code_body: %w", name, verr)
			}
		} else {
			_, err = cp.Compile(name)
		}
		if err != nil {
			broken = append(broken, err.Error())
		}
	}
	if len(broken) > 0 {
		log.Fatalf("code-js agents failed to load:\n  - %s", strings.Join(broken, "\n  - "))
	}
	if cp != nil {
		log.Printf("code-js: validated %d static code-agent(s)", countCodeAgents(cfg))
	}
}

func countCodeAgents(cfg *config.Config) int {
	n := 0
	for _, def := range cfg.Agents {
		if def.Provider == "code-js" {
			n++
		}
	}
	return n
}

// Get returns the constructed provider for id, or the pre-P2a-identical error.
// A hit is a map lookup. On a miss: a declared-but-disabled provider (present in
// cfg.Providers, skipped by providerEnabled) — or the residual anthropic-oauth-dev
// — gets its bespoke "not configured" message; a truly unknown id gets the
// "unknown provider" error.
func (p *providerResolver) Get(id string) (providers.Provider, error) {
	if prov, ok := p.byID[id]; ok {
		return prov, nil
	}
	if id == "anthropic-oauth-dev" {
		return nil, notConfiguredError(id, p.cfg)
	}
	if _, declared := p.cfg.Providers[id]; declared {
		return nil, notConfiguredError(id, p.cfg)
	}
	return nil, fmt.Errorf("unknown provider %q", id)
}

// buildResolver constructs the model-resolution matrix
// (resolve.Resolver) and runs the FIRST round of live probes
// synchronously before returning. Per the operator's directive:
// providers without API keys are explicitly marked Excluded so
// Snapshot() distinguishes "operator chose not to enable this
// provider" from "operator enabled but the probe failed".
//
// The first probe completes synchronously so traffic served on the
// HTTP/gRPC port immediately after this returns sees a populated
// matrix (or an explicit Excluded marker for unconfigured providers).
// Subsequent rounds run in a background goroutine started by main —
// see runResolveProbeLoop.
func buildResolver(cfg *config.Config, pr *providerResolver) *resolve.Resolver {
	libraryTiers := convertTiers(cfg.Tiers, cfg.Models)
	r := resolve.NewResolver(cfg.ProviderPriority, libraryTiers)
	// RFC BG: surface a silent model-pattern bump (a pattern resolving to a new
	// concrete model) in the operator log. Set before the first probe so an
	// initial resolution is recorded (first-seen never warns; only a MOVE does).
	r.SetLogf(log.Printf)

	// First-round probe — synchronous, so the matrix is hot before
	// the listener accepts traffic.
	runResolveProbeOnce(context.Background(), r, pr, cfg)
	return r
}

// runResolveProbeOnce performs one full sweep across all four
// providers: explicitly Excludes those without keys (visible in
// Snapshot), and live-probes the rest. Used by buildResolver for
// the synchronous startup probe and by runResolveProbeLoop for
// every periodic re-probe.
//
// Each provider's probe runs in its own goroutine with a 5-second
// deadline so a slow provider doesn't hold the whole sweep. The
// caller's ctx (typically bgCtx for the periodic loop, or
// context.Background for startup) bounds the total wait.
func runResolveProbeOnce(ctx context.Context, r *resolve.Resolver, pr *providerResolver, cfg *config.Config) {
	type probeJob struct {
		id         string             // provider id
		excluded   bool               // operator opted out
		exclReason string             // why excluded; surfaced in LastError
		provider   providers.Provider // nil when excluded
	}

	// RFC BF P2a — build the probe set from cfg.Providers (config-driven), deriving
	// excluded/reason from providerEnabled + providerDisabledReason (byte-identical
	// to the pre-P2a jobs slice). code-js is skipped: pre-P2a never probed it, so
	// its resolver-matrix presence is unchanged. Sorted for deterministic log order
	// (the reachable probes below run concurrently, so line order was never stable).
	// The residual anthropic-oauth-dev job (not in cfg.Providers) is appended — its
	// env/token gates are caught by pr.Get in the loop below.
	ids := make([]string, 0, len(cfg.Providers))
	for id := range cfg.Providers {
		if id == "code-js" {
			continue
		}
		ids = append(ids, id)
	}
	sort.Strings(ids)
	jobs := make([]probeJob, 0, len(ids)+1)
	for _, id := range ids {
		pc := cfg.Providers[id]
		excluded := !providerEnabled(id, pc, cfg)
		reason := ""
		if excluded {
			reason = providerDisabledReason(id, pc)
		}
		jobs = append(jobs, probeJob{id: id, excluded: excluded, exclReason: reason})
	}
	jobs = append(jobs, probeJob{id: "anthropic-oauth-dev"})
	for i := range jobs {
		if jobs[i].excluded {
			continue
		}
		if p, err := pr.Get(jobs[i].id); err == nil {
			jobs[i].provider = p
		} else {
			// Provider configured but resolver-Get failed. Mark
			// Excluded with the resolver error as the reason —
			// rare in practice (newProviderResolver wires every
			// driver whose key is set) but defensive.
			jobs[i].excluded = true
			jobs[i].exclReason = fmt.Sprintf("provider not registered: %v", err)
		}
	}

	var wg sync.WaitGroup
	for _, j := range jobs {
		if j.excluded {
			r.SetExcluded(j.id, j.exclReason)
			log.Printf("resolve probe: %s excluded (%s)", j.id, j.exclReason)
			continue
		}
		wg.Add(1)
		go func(j probeJob) {
			defer wg.Done()
			probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
			defer cancel()
			models, err := j.provider.ListModels(probeCtx)
			if err != nil {
				r.SetReachable(j.id, false, nil, err.Error())
				log.Printf("resolve probe: %s unreachable (%v)", j.id, err)
				return
			}
			r.SetReachable(j.id, true, models, "")
			log.Printf("resolve probe: %s reachable (%d models listed)", j.id, len(models))
		}(j)
	}
	wg.Wait()
}

// runResolveProbeLoop runs runResolveProbeOnce on the configured
// interval until ctx is cancelled. Started as a goroutine from main;
// shares bgCtx with the heartbeat sweeper and session-lock GC so
// SIGTERM tears all three down together.
func runResolveProbeLoop(ctx context.Context, r *resolve.Resolver, pr *providerResolver, cfg *config.Config, interval time.Duration) {
	t := time.NewTicker(interval)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			runResolveProbeOnce(ctx, r, pr, cfg)
		}
	}
}

// convertTiers translates the config-package representation of library
// tier definitions into the resolver-package representation. Mirrors
// the per-agent helper in internal/api/http/server.go but operates on
// the top-level cfg.Tiers map.
func convertTiers(in map[string][]config.TierCandidate, models map[string]config.ModelRef) map[string][]resolve.Candidate {
	if len(in) == 0 {
		return nil
	}
	out := make(map[string][]resolve.Candidate, len(in))
	for tier, cands := range in {
		conv := make([]resolve.Candidate, 0, len(cands))
		for _, c := range cands {
			// Expand model aliases (top-level models:) — mirrors the pin
			// path and the per-agent converter so a library tier candidate
			// can name an alias too. RFC BG: a pattern alias surfaces a glob
			// (pat) the resolver resolves against the live catalog.
			prov, mdl, pat := config.ExpandModelAlias(models, c.Provider, c.Model)
			conv = append(conv, resolve.Candidate{Provider: prov, Model: mdl, ModelPattern: pat})
		}
		out[tier] = conv
	}
	return out
}

// runAdvisoryGatedSweeper drives a per-tick fn behind an optional
// Postgres advisory lock. v0.12.4 Phase 5 — every TTL sweeper goes
// through this helper. When lock is nil (single-replica mode), tick
// runs unconditionally. When lock is set (cluster mode), only the
// replica that wins pg_try_advisory_lock for this tick actually runs
// the body. Lost-race ticks are silent.
//
// Replaces the v0.8.x runMemorySweeper / runChannelsSweeper /
// runInterruptsSweeper / runMetricsSweeper functions which all had
// the same ticker boilerplate. Each per-tick body is now a closure
// passed at call site.
func runAdvisoryGatedSweeper(
	ctx context.Context,
	interval time.Duration,
	lock *coord.AdvisoryLock,
	lockKey int64,
	name string,
	tick func(ctx context.Context),
) {
	t := time.NewTicker(interval)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			if lock == nil {
				tick(ctx)
				continue
			}
			acquired, err := lock.TryRun(ctx, lockKey, func(ctx context.Context) error {
				tick(ctx)
				return nil
			})
			if err != nil {
				log.Printf("%s sweeper: advisory lock infra error: %v", name, err)
				continue
			}
			_ = acquired // silent lost race
		}
	}
}

// spawnStdioMCP starts a stdio MCP child for one server entry. Env keys are
// sorted so process listings are deterministic across runs.
// mcpLookupView adapts *mcp.DynamicRegistry to lookup.MCPDynamicRegistry
// so the pool's build callback + the boot rehydrator can resolve through
// the shared lookup.MCPServer chain (RFC N). The dynamic registry stores
// mcp.DynamicMCPServerSpec; lookup wants the uniform lookup.MCPServerSpec.
// A dynamic spec carries no operator tools (the substrate doesn't
// record a narrowing), so Tools stays nil = allow-all.
type mcpLookupView struct{ reg *mcp.DynamicRegistry }

func (v mcpLookupView) Get(tenantID, name string) (lookup.MCPServerSpec, bool) {
	if v.reg == nil {
		return lookup.MCPServerSpec{}, false
	}
	s, ok := v.reg.Get(tenantID, name)
	if !ok {
		return lookup.MCPServerSpec{}, false
	}
	return lookup.MCPServerSpec{
		Transport: s.Transport, URL: s.URL, Headers: s.Headers,
		Command: s.Command, Args: s.Args, Env: s.Env, // stdio (F31)
	}, true
}

func spawnStdioMCP(name string, srv config.MCPServer) (mcp.Caller, error) {
	return spawnStdio(name, srv.Command, srv.Args, srv.Env)
}

// runStartMCPDiscoveryTimeout bounds the F33 run-start handshake of a referenced
// dynamic MCP server whose discovered_tools cache is empty. Kept well under the
// 30s boot-discovery budget: a run shouldn't stall long waiting on one peer, and
// if it times out the lazy first-call fallback still applies.
const runStartMCPDiscoveryTimeout = 10 * time.Second

// mcpActiveDefReader adapts the store to mcp.ActiveDefReader (F33), returning the
// active dynamic-MCP-server def JSON for (tenant, name). Keeps internal/tools/mcp
// free of an internal/store import.
type mcpActiveDefReader struct{ store store.Store }

func (r mcpActiveDefReader) ActiveMCPServerDef(ctx context.Context, tenant, name string) (json.RawMessage, bool) {
	row, err := r.store.MCPServerDefGetActive(ctx, tenant, name)
	if err != nil {
		return nil, false
	}
	return row.Definition, true
}

// spawnStdio is the transport-spawn core shared by the static yaml stdio
// path (spawnStdioMCP) and the dynamic stdio path (the pool build
// callback's stdio case, F31).
//
// F39: command / args / env values are ExpandEnv'd HERE, at spawn. The static
// yaml path arrives pre-expanded (config-load ExpandEnvs the whole file), so
// this is an idempotent no-op for it; the DYNAMIC path stores raw ${...}
// placeholders (kept literal in the def per F32) that never went through the
// yaml-wide expansion, so without this a dynamic stdio server received the
// literal "${LOOMCYCLE_X}" (and clobbered the inherited real var, since
// cmd.Env appends def env AFTER the inherited environ). Expanding at spawn —
// not baking into the stored def — keeps the persisted content free of the
// resolved secret (F32). Uses the same allowlisted expander as yaml-load;
// ${run.*} late-binding tokens pass through verbatim as before.
func spawnStdio(name, command string, args []string, envMap map[string]string) (mcp.Caller, error) {
	expandedArgs := make([]string, len(args))
	for i, a := range args {
		expandedArgs[i] = config.ExpandEnv(a)
	}
	return mcpstdio.Spawn(mcpstdio.Config{
		Command: config.ExpandEnv(command),
		Args:    expandedArgs,
		Env:     expandedStdioEnv(envMap),
		OnStderr: func(line string) {
			log.Printf("mcp[%s]: %s", name, line)
		},
	})
}

// expandedStdioEnv renders an stdio env map as sorted KEY=VALUE strings,
// ExpandEnv'ing each VALUE (F39). Sorted for a deterministic child env
// regardless of map iteration order. Pure (no spawn) so the expansion is
// unit-testable. See spawnStdio for the static-vs-dynamic rationale + F32.
func expandedStdioEnv(envMap map[string]string) []string {
	keys := make([]string, 0, len(envMap))
	for k := range envMap {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	env := make([]string, 0, len(keys))
	for _, k := range keys {
		env = append(env, k+"="+config.ExpandEnv(envMap[k]))
	}
	return env
}

// applyToolsFilter — thin wrapper over mcp.ApplyToolsFilter.
// Lives in the mcp package so both the boot-time path here and the
// lazy-retry path (mcp.LazyResolver) share one implementation.
func applyToolsFilter(descs []mcp.ToolDescriptor, allowed []string) []mcp.ToolDescriptor {
	return mcp.ApplyToolsFilter(descs, allowed)
}

// bootstrapMemoryEntries walks cfg.Memory.Entries and writes any
// missing rows to the substrate. Idempotent: existing rows (matched
// by the (scope, scopeID, key) tuple) are left alone so runtime
// updates from the Memory tool / admin endpoints are preserved across
// reboots — yaml is a starting state, not a re-baseline.
//
// When an entry sets embed: true and the operator wired
// memory.embedder yaml, the embedding is computed synchronously and
// upserted. Boot prints a per-entry log line so the operator sees the
// time cost of embedding many entries.
func bootstrapMemoryEntries(ctx context.Context, cfg *config.Config, st store.Store, embedder providers.Embedder) {
	t0 := time.Now()
	loaded, skipped, failed := 0, 0, 0
	for i, e := range cfg.Memory.Entries {
		scope := store.MemoryScope(strings.TrimSpace(e.Scope))
		if scope == "" {
			scope = store.MemoryScopeGlobal
		}
		switch scope {
		case store.MemoryScopeGlobal, store.MemoryScopeAgent, store.MemoryScopeUser:
		default:
			log.Printf("memory.entries[%d]: skipping — invalid scope %q (must be global|agent|user)", i, e.Scope)
			failed++
			continue
		}
		key := strings.TrimSpace(e.Key)
		if key == "" {
			log.Printf("memory.entries[%d]: skipping — empty key", i)
			failed++
			continue
		}
		if _, err := st.MemoryGet(ctx, "", scope, e.ScopeID, key); err == nil {
			// Row already present — yaml seeding is a one-time
			// bootstrap, never an overwrite. This is the line that
			// makes the loader safe to re-run on every boot.
			//
			// Non-nil err here covers BOTH "row missing"
			// (ErrNotFound — the expected path on a fresh row) AND
			// transient store errors (e.g. a dropped DB connection).
			// We deliberately don't distinguish: the MemorySet
			// attempt below will fail loudly on a broken store and
			// the failure gets logged + counted in `failed`. The
			// alternative — bailing out on every Get error — would
			// mean a flaky DB at boot leaves the entries unseeded
			// without retry. Fail-on-Set lets a recovered store on
			// the next boot pick up where we left off.
			skipped++
			continue
		}
		valBytes, marshalErr := json.Marshal(e.Value)
		if marshalErr != nil {
			log.Printf("memory.entries[%d] (%s/%s/%s): skipping — value marshal failed: %v",
				i, scope, e.ScopeID, key, marshalErr)
			failed++
			continue
		}
		if err := st.MemorySet(ctx, "", scope, e.ScopeID, key, valBytes, 0); err != nil {
			log.Printf("memory.entries[%d] (%s/%s/%s): set failed: %v",
				i, scope, e.ScopeID, key, err)
			failed++
			continue
		}
		loaded++
		if !e.Embed {
			continue
		}
		// embed:true is best-effort — the k/v row stays even if the
		// embedding fails, matching the v0.9.0 Memory.set posture.
		if embedder == nil {
			log.Printf("memory.entries[%d] (%s/%s/%s): embed requested but memory.embedder is not configured — k/v written without embedding",
				i, scope, e.ScopeID, key)
			continue
		}
		if !st.SupportsVectors() {
			log.Printf("memory.entries[%d] (%s/%s/%s): embed requested but store does not support vectors — k/v written without embedding",
				i, scope, e.ScopeID, key)
			continue
		}
		te0 := time.Now()
		vecs, embedErr := embedder.Embed(ctx, []string{string(valBytes)})
		if embedErr != nil || len(vecs) != 1 {
			log.Printf("memory.entries[%d] (%s/%s/%s): embed failed: %v",
				i, scope, e.ScopeID, key, embedErr)
			continue
		}
		// Dimension is the OBSERVED width, never embedder.Dimension(): a driver
		// backed by a static (model → dim) table returns 0 for any model outside
		// it, and a stored 0 alongside a real vector poisons every later search
		// in this scope with dimension_mismatch. len(vecs[0]) is always true.
		if err := st.MemoryEmbedSet(ctx, "", scope, e.ScopeID, key, store.MemoryEmbedding{
			Provider:  embedder.Provider(),
			Model:     embedder.Model(),
			Dimension: len(vecs[0]),
			Vector:    vecs[0],
			EmbedText: string(valBytes),
			CreatedAt: time.Now().UTC(),
		}); err != nil {
			log.Printf("memory.entries[%d] (%s/%s/%s): embed write failed: %v",
				i, scope, e.ScopeID, key, err)
			continue
		}
		log.Printf("memory.entries[%d] (%s/%s/%s): seeded + embedded (%s)",
			i, scope, e.ScopeID, key, time.Since(te0).Round(time.Millisecond))
	}
	log.Printf("memory.entries: bootstrap complete — loaded=%d skipped=%d failed=%d elapsed=%s",
		loaded, skipped, failed, time.Since(t0).Round(time.Millisecond))
}

// buildSearchProviders assembles the RFC BB search catalog from config: the
// provider registry, the fallback resolver (explicit search_priority, else the
// enabled set sorted for determinism), and the operator host-key map. Returns
// (nil, nil, nil) when no providers are configured — WebSearch then refuses with
// "not configured". config.Validate has already checked the enabled set, so a
// BuildRegistry error here is a bug (logged, WebSearch disabled).
//
// Back-compat: pre-RFC-BB, WebSearch worked on BRAVE_API_KEY alone. With no
// explicit search_providers, default to a single Brave provider when its key is
// set so existing deployments keep working with no config change.
func buildSearchProviders(cfg *config.Config) (*search.Registry, *search.Resolver, map[string]string) {
	hostKeys := map[string]string{}
	var specs []search.ProviderSpec
	var ids []string
	if len(cfg.SearchProviders) == 0 {
		if cfg.Env.BraveAPIKey != "" {
			specs = []search.ProviderSpec{{ID: "brave"}}
			hostKeys["brave"] = cfg.Env.BraveAPIKey
			ids = []string{"brave"}
		}
	} else {
		for id, spc := range cfg.SearchProviders {
			specs = append(specs, search.ProviderSpec{ID: id, BaseURL: spc.BaseURL})
			if k := cfg.SearchHostKey(id); k != "" {
				hostKeys[id] = k
			}
			ids = append(ids, id)
		}
	}
	if len(specs) == 0 {
		return nil, nil, nil
	}
	reg, err := search.BuildRegistry(specs)
	if err != nil {
		log.Printf("search: registry build error: %v (WebSearch disabled)", err)
		return nil, nil, nil
	}
	priority := cfg.SearchPriority
	if len(priority) == 0 {
		sort.Strings(ids)
		priority = ids
	}
	return reg, search.NewResolver(priority), hostKeys
}

// deadLinkMemoryScope maps a SQL-Memory scope onto the Memory scope its chunk
// bodies live in, and reports whether the scope is worth reconciling at all.
//
// `run` is excluded: an ephemeral scope is dropped whole at run end, so collecting
// its orphans is work with no beneficiary — and it has no durable body partition to
// reconcile against, so guessing one risks deleting another scope's bodies. The same
// exclusion the retention content-prune makes, for the same reason.
func deadLinkMemoryScope(scope string) (store.MemoryScope, bool) {
	switch scope {
	case "agent":
		return store.MemoryScopeAgent, true
	case "user":
		return store.MemoryScopeUser, true
	case "tenant":
		return store.MemoryScopeTenant, true
	}
	return "", false
}
