/**
 * # catalog/session-catalog — the public, versioned, content-addressed Session catalog (kestrel-djm.8)
 *
 * The publication-safe SYNTHETIC fixtures promoted into a VERSIONED, CONTENT-ADDRESSED public catalog: one
 * {@link SESSION_CATALOG} manifest of generic Session artifacts a client can RUN LOCALLY end-to-end through
 * djm.4's incremental Session controller ({@link openSession} → `start` → `advance` → `finalize` → Blotter →
 * graded bus) to reproduce each entry's pinned conformance root, byte-for-byte. The SAME manifest is
 * mirrorable by kestrel.markets WITHOUT rewriting, because every entry EMBEDS a djm.2 {@link CatalogEntry}
 * ({@link ./protocol/catalog.ts} — the ONE catalog contract, never forked) verbatim as `.entry`, and the
 * platform mirror consumes exactly {@link catalogEntries} — the same `CatalogEntry[]`, no rewrite.
 *
 * ## What each entry pins (djm.8 acceptance)
 *  - content roots (content-addressed): the input TAPE root (`entry.provenance.source`) and the graded-bus
 *    CONFORMANCE root (`sha256:<expectedConformanceRoot>`), both in `entry.contentRoots` (ADR-0010/0011: the
 *    graded bus is the recorded truth; a projection pins here).
 *  - provenance/rights + corpus tier: `entry.provenance` + `entry.corpusTier` (a PUBLIC tier).
 *  - engine/Judge compatibility: `entry.requirements` (a client whose engine/Judge drift MUST fail closed).
 *  - fill policy: `entry.permittedFillModel` (the single graded model — the two-bus rule).
 *  - View/Rendering identities: `entry.viewIdentity` / `entry.renderingIdentity` (content-addressed handles).
 *  - the local RUN RECIPE (`.recipe`) + the shipped generic tape (`.tape`) — the non-contract legs the LOCAL
 *    package runs on; the platform mirror never needs them (it resolves the content roots server-side).
 *
 * ## FAIL CLOSED (djm.8 AC3) — a typed refusal, never a silent load/run
 * {@link loadSessionCatalog} / {@link runCatalogEntry} / {@link verifyCatalogEntry} refuse a corrupted
 * content root (`corrupt-content-root`), an unsupported schema (`unsupported-schema`), or an unknown fill
 * model (`unknown-fill-model`) with a typed {@link SessionCatalogError} — the entry is never partially loaded
 * or run against a drifted implementation. {@link resolveTapeEvents} is a content-addressed resolver: a tape
 * whose bytes no longer hash to `entry.provenance.source` is refused too.
 *
 * ## DETERMINISM (djm.8 AC2) — content-addressed, byte-stable
 * The conformance root IS `blotter.session.bus.sha256` (= `SimRunId(gradedBus)`, run-identity.ts) — a pure,
 * host-independent hash of the graded bus (no wall clock, no RNG on the record path). Same catalog + same
 * engine ⇒ the same root, every run. {@link runCatalogEntry} drives the REAL controller (Bus→Blotter→Grade),
 * so a catalog run is byte-identical to an independent `openSession → start → advance → finalize` drive.
 *
 * ── DESIGN FORK LOGGED (hard rules forbid .beads writes — recorded here like djm.4's 7dv.4 note) ──
 * djm.2's {@link CatalogEntry} is FROZEN and carries neither the local run recipe nor a dedicated
 * `expectedConformanceRoot`, so a bare `CatalogEntry[]` is not locally runnable. MINIMAL shape consistent
 * with djm.2 + content-addressing: a versioned WRAPPER ({@link SessionCatalogManifest}) whose entries embed a
 * `CatalogEntry` VERBATIM (`.entry`) alongside the execution `.recipe`, the shipped generic `.tape`, and the
 * bare `.expectedConformanceRoot`. The graded-bus conformance root is ALSO pinned INSIDE the djm.2 entry as a
 * content root (`sha256:<root>` ∈ `entry.contentRoots`), so it reconciles with djm.2's existing field without
 * a second contract; the mirror view is `catalogEntries()` → the same `CatalogEntry[]`. Roots are regenerable
 * via `scripts/catalog/compute-catalog-roots.ts`. Generic tickers only (SPX/SPY/QQQ), ARCHITECTURE §7.
 */

import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

import { readBus, serializeBus, type BusEvent } from "../bus/index.ts";
import { sha256 } from "../crypto/sha256.ts";
import type { Blotter } from "../blotter/index.ts";
import type { CatalogEntry, CatalogListing, InputPin } from "../protocol/catalog.ts";
import { buildCatalogListing } from "./catalog-listing.ts";
import type { Brief, Mandate } from "../frame/index.ts";
import { PROTOCOL_VERSION } from "../protocol/index.ts";
import {
  BACKTEST_CONFIG,
  cellConfigId,
  declaresEnvelope,
  openSession,
  type AgentConfig,
  type FillModelName,
  type SessionController,
  type TimeOfDay,
} from "../session/index.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Public surface — the versioned manifest + its embedded-entry wrapper
// ─────────────────────────────────────────────────────────────────────────────

/** The local RUN RECIPE the LOCAL package drives an entry on — the legs djm.2's frozen {@link CatalogEntry}
 * deliberately does not carry (it pins reproducibility facts, not an executable script). Passed VERBATIM to
 * {@link openSession}; the fill model rides {@link CatalogEntry.permittedFillModel} (the one graded model), so
 * it is not duplicated here. */
export interface SessionRecipe {
  /** The initial armed Kestrel document authored at OPEN (a NO-LLM Backtest: arm once, pass forever). */
  readonly document: string;
  /** The authored wake vantages (ET times-of-day), strictly inside the driven window. */
  readonly wakes: readonly TimeOfDay[];
  /** The dollar value of one risk unit `R` for this grade. */
  readonly rUsd: number;
  /** The cell's hard {@link Mandate} (ADR-0026) — objective + R-definition + success criterion +
   * bounded-risk rule the agent is graded against. Rendered in the kernel; the ONLY admission input.
   * OPTIONAL here (a NO-LLM backtest entry declares none); a GRADED LLM run MUST declare one. */
  readonly mandate?: Mandate;
  /** The cell's soft {@link Brief} (ADR-0026) — directional English (goal/approach/persona), rendered
   * as standing context + hashed + attributed, but NEVER an admission input (the hard guard). */
  readonly brief?: Brief;
  /** The injected constant fair-tau (years) function — a documented sim simplification. */
  readonly fairTauYears: (now: number) => number | null;
}

/**
 * One public Session catalog entry: a djm.2 {@link CatalogEntry} embedded VERBATIM (`.entry` — the mirrorable
 * contract) alongside the shipped generic `.tape`, the local `.recipe`, and the bare `.expectedConformanceRoot`
 * (which is ALSO pinned as `sha256:<root>` inside `entry.contentRoots`). `id` is a stable, human-readable
 * identifier the executable examples / paper reproduction / practice benchmarks share.
 */
export interface SessionCatalogEntry {
  /** Stable content-addressable-independent identifier (shared across examples/paper/benchmarks). */
  readonly id: string;
  /** The djm.2 catalog contract, embedded verbatim (the ONE contract — never forked). */
  readonly entry: CatalogEntry;
  /** The shipped generic tape fixture basename (resolved under `fixtures/`, content-addressed to
   * `entry.provenance.source`). */
  readonly tape: string;
  /** The local execution recipe (djm.2's `CatalogEntry` carries no runnable script). */
  readonly recipe: SessionRecipe;
  /** The pinned graded-bus conformance root (a bare 64-hex sha256; also in `entry.contentRoots`). */
  readonly expectedConformanceRoot: string;
}

/** The VERSIONED manifest: a version tag + the public entries. The version is bumped on any change to the
 * pinned roots / recipes / entry set, so a consumer can pin an exact catalog revision. */
export interface SessionCatalogManifest {
  readonly version: string;
  readonly entries: readonly SessionCatalogEntry[];
}

/** The catalog schema version (independent of {@link PROTOCOL_VERSION}, which the embedded djm.2 entries
 * track). Bumped when the pinned roots / entries change.
 *
 * Bumped 1.0.0 → 1.1.0 (kestrel-a57.18): ADDITIVE, schema-stable re-pin. The graded session bus now carries
 * one agent-decision RECEIPT — a CONTROL `propose` per armed PLAN — so the restraint benchmark's `grade/receipts`
 * projector is non-empty on a real session (it was INERT in production before). No wire field was added or
 * removed; only the pinned conformance roots moved, by EXACTLY that +1 receipt-per-PLAN event (verified: stripping
 * the propose events reproduces the pre-change roots byte-for-byte). A MINOR bump is the honest SemVer signal for
 * a backward-additive change with an unchanged schema. Pre-launch — nobody had pinned 1.0.0 yet. No ADR mandates a
 * specific catalog-version policy (rg over docs/adr found none), so SemVer-additive was applied; the engine-epoch
 * bump that the root move requires is {@link ENGINE_VERSION} kestrel-engine/2 → /3 below. */
export const SESSION_CATALOG_VERSION = "1.1.0";

// ─────────────────────────────────────────────────────────────────────────────
// Fail-closed error surface (djm.8 AC3)
// ─────────────────────────────────────────────────────────────────────────────

/** The typed refusal codes: a corrupted/mismatched content root, an unsupported schema, an unknown fill
 * model — the three fail-closed cases djm.8 mandates — plus the two m9i.27 plan-artifact refusals: a
 * fixed-plan cell whose pinned doc is MISSING (`plan-doc-missing`) or whose stored doc does NOT hash to its
 * pin (`plan-doc-mismatch`). A plan artifact gets its OWN typed refusal, distinct from a tape's
 * `corrupt-content-root`. */
export type SessionCatalogErrorCode =
  | "corrupt-content-root"
  | "unsupported-schema"
  | "unknown-fill-model"
  | "plan-doc-missing"
  | "plan-doc-mismatch";

/** A typed refusal from the Session catalog. Carries a {@link SessionCatalogErrorCode} so a caller can branch
 * fail-closed without string-matching a message (never a silent load/run). */
export class SessionCatalogError extends Error {
  readonly code: SessionCatalogErrorCode;
  constructor(code: SessionCatalogErrorCode, message: string) {
    super(message);
    this.name = "SessionCatalogError";
    this.code = code;
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Content-addressing helpers (pure, deterministic — no wall clock, no RNG)
// ─────────────────────────────────────────────────────────────────────────────

const HEX64 = /^[0-9a-f]{64}$/;

/**
 * The supported catalog wire schema for an embedded djm.2 entry (e.g. `kestrel.session/0.2`). A different
 * schema fails closed as `unsupported-schema`.
 *
 * HONEST COUPLING (kestrel-b83) — this INTENTIONALLY and correctly tracks {@link PROTOCOL_VERSION}; it is NOT
 * the 2t0 silent-drift bug that {@link ENGINE_VERSION} / {@link JUDGE_VERSION} froze. Those are grade/replay
 * EPOCHS whose behaviour can change independently of the wire minor, so they must be frozen. This tag, by
 * contrast, IS the wire-protocol shape: `kestrel.session/<minor>` names the exact `CatalogEntry` shape a client
 * parses, so a session-schema change IS a protocol-minor change by definition — the two CANNOT move
 * independently, and freezing this to an epoch decoupled from the protocol would be the dishonest move (a
 * schema tag diverging from the protocol it names). It therefore correctly tracks PROTOCOL_VERSION, and AC1
 * pins `entry.schema === kestrel.session/${PROTOCOL_VERSION}`. The only b83 gap was that this coupling was
 * SILENT; this comment makes it explicit.
 */
const SUPPORTED_SCHEMA = `kestrel.session/${PROTOCOL_VERSION}`;

/** The known fill models an entry may be graded under (RUNTIME §6). Any other name fails closed as
 * `unknown-fill-model` — the entry is never run against an unknown model. */
const KNOWN_FILL_MODELS: readonly FillModelName[] = ["strict-cross-v1", "maker-fair-v1"];

/** The content address of a bus (tape or graded): the codebase's ONE bus serializer → the ONE portable
 * sha256 (`SimRunId`/`blotter.session.bus.sha256` use the identical convention), so a tape root here and a
 * conformance root there are the same kind of content id. */
function busRoot(events: readonly BusEvent[]): string {
  return sha256(serializeBus(events));
}

/** Strip the `sha256:` prefix off a djm.2 {@link import("../protocol/catalog.ts").ContentRoot} handle, or
 * `null` when it is not a well-formed `sha256:<64-hex>` address. */
function bareRootOf(handle: string): string | null {
  const m = /^sha256:([0-9a-f]{64})$/.exec(handle);
  return m === null ? null : m[1]!;
}

function msgOf(e: unknown): string {
  return e instanceof Error ? e.message : String(e);
}

/**
 * The absolute path to a shipped tape fixture, resolved relative to THIS module — so it resolves
 * whether the package is consumed from `src/` under Bun or from the bundled `dist/` under node.
 *
 * `fileURLToPath(import.meta.url)`, NOT `import.meta.dir`: the latter is a Bun-ism that
 * `bun build --target=node` passes through VERBATIM, and under node it is `undefined`. That made
 * `join(undefined, …)` throw a raw TypeError deep inside the catalog — surfacing to an agent as
 * `corrupt-content-root` ("your content root is corrupt") when the truth was "this host has no Bun"
 * (kestrel-mkn review B1). The node-portable form is the only one that can be trusted in shipped code.
 */
function fixturePath(tape: string): string {
  return join(dirname(fileURLToPath(import.meta.url)), "fixtures", tape);
}

// ─────────────────────────────────────────────────────────────────────────────
// Plan-doc / config INPUT PIN — content-addressed, recoverable, fail-closed (kestrel-m9i.27)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Content-address a FIXED plan doc: `sha256:` + the doc's OWN sha256 — the run-identity/SimRunId
 * convention (`busRoot` / `deriveConfigId` use the identical one sha256), NOT a name. Deterministic: the
 * SAME doc ⇒ the SAME pin; a CHANGED doc ⇒ a CHANGED pin — so a plan-doc difference is VISIBLE in the pin
 * (two different docs authored under one plan NAME get different pins, unlike the name-only `plan_instance`
 * id that collides them). Pure — no wall clock, no RNG.
 */
export function planDocPin(text: string): string {
  return `sha256:${sha256(text)}`;
}

/**
 * Derive the {@link InputPin} a cell pins from its run config + authored doc (kestrel-m9i.27). Discriminates
 * on {@link declaresEnvelope} — the SAME split the run-identity cell axis uses, so the pin and the cell axis
 * never drift:
 *  - a NO-LLM Backtest declares no a57.14 envelope ⇒ there IS a fixed plan doc ⇒ pin the DOC
 *    ({@link planDocPin} — recoverable byte-for-byte);
 *  - a live-authoring config declares an envelope ⇒ the agent authors the plan LIVE ⇒ pin the CONFIG
 *    (the {@link cellConfigId cell ConfigId} as a content root — the reproducible input is the config, not
 *    a doc).
 * Pure — no wall clock, no RNG.
 */
export function deriveInputPin(config: AgentConfig, planDoc: string): InputPin {
  if (declaresEnvelope(config)) {
    return { kind: "agent-authored", config: `sha256:${cellConfigId(config)}` };
  }
  return { kind: "fixed-plan", planDoc: planDocPin(planDoc) };
}

/**
 * Recover a fixed-plan cell's EXACT authored plan doc from the manifest, content-address VERIFIED: the
 * stored doc (`e.recipe.document` — the content-addressed store the LOCAL package ships) is returned ONLY
 * when its sha256 equals the pinned `planDoc` hash, so the returned string is BYTE-FOR-BYTE the authored
 * plan the run drove on. A pin that is missing or agent-authored (no recoverable doc) fails closed as
 * `plan-doc-missing`; a stored doc that does NOT hash to its pin fails closed as `plan-doc-mismatch` —
 * never a silently-unverified doc (a benchmark cell is never treated as reproducible on trust). Pure.
 */
export function resolvePlanDoc(e: SessionCatalogEntry): string {
  const pin = e.entry.inputPin;
  if (pin === undefined || pin.kind !== "fixed-plan") {
    throw new SessionCatalogError(
      "plan-doc-missing",
      `entry ${e.id}: no fixed-plan doc to resolve (${pin === undefined ? "no inputPin" : `pin kind ${pin.kind}`})`,
    );
  }
  const doc = e.recipe.document;
  const expected = bareRootOf(pin.planDoc);
  const actual = sha256(doc);
  if (expected === null || actual !== expected) {
    throw new SessionCatalogError(
      "plan-doc-mismatch",
      `entry ${e.id}: stored plan doc (sha256 ${actual}) does not match pinned ${JSON.stringify(pin.planDoc)}`,
    );
  }
  return doc;
}

/**
 * Fail-closed verification of a cell's plan artifact (kestrel-m9i.27): a fixed-plan cell MUST pin its doc
 * AND the stored doc MUST hash to the pin; an agent-authored cell MUST pin a well-formed config content
 * root. A MISSING pin, a MISMATCHED doc, or a malformed config pin throws a typed {@link SessionCatalogError}
 * (`plan-doc-*`) — the cell is flagged incomplete, NEVER silently treated as reproducible. A shipped
 * {@link SessionCatalogEntry} drives a NO-LLM Backtest, so it MUST carry a fixed-plan pin whose doc is
 * recoverable. Pure — no side effects, no wall clock.
 */
export function verifyPlanArtifact(e: SessionCatalogEntry): void {
  const pin = e.entry.inputPin;
  if (pin === undefined) {
    throw new SessionCatalogError(
      "plan-doc-missing",
      `entry ${e.id}: fixed-plan cell carries no input pin — its authored plan doc is unrecoverable (fail closed)`,
    );
  }
  if (pin.kind === "fixed-plan") {
    resolvePlanDoc(e); // re-hash the stored doc against the pin (throws plan-doc-mismatch on any drift)
    return;
  }
  // agent-authored: no fixed doc — the reproducible input is the CONFIG; verify it is a real content root.
  if (bareRootOf(pin.config) === null) {
    throw new SessionCatalogError(
      "plan-doc-mismatch",
      `entry ${e.id}: agent-authored config pin ${JSON.stringify(pin.config)} is not a sha256 content root`,
    );
  }
}

/**
 * Fail-closed structural validation of ONE entry, independent of running it: the embedded schema is
 * supported, the fill model is known, the pinned conformance root is a bare 64-hex that is ALSO
 * content-addressed into `entry.contentRoots` (the graded bus is the recorded truth), AND the m9i.27 plan
 * artifact is present + content-verified ({@link verifyPlanArtifact}). Throws a typed
 * {@link SessionCatalogError} on the first violation — the entry is never partially accepted.
 */
function assertEntryValid(e: SessionCatalogEntry): void {
  if (e.entry.schema !== SUPPORTED_SCHEMA) {
    throw new SessionCatalogError(
      "unsupported-schema",
      `entry ${e.id}: schema ${JSON.stringify(e.entry.schema)} is not the supported ${JSON.stringify(SUPPORTED_SCHEMA)}`,
    );
  }
  if (!KNOWN_FILL_MODELS.includes(e.entry.permittedFillModel as FillModelName)) {
    throw new SessionCatalogError(
      "unknown-fill-model",
      `entry ${e.id}: permittedFillModel ${JSON.stringify(e.entry.permittedFillModel)} is not a known fill model`,
    );
  }
  if (!HEX64.test(e.expectedConformanceRoot)) {
    throw new SessionCatalogError(
      "corrupt-content-root",
      `entry ${e.id}: expectedConformanceRoot ${JSON.stringify(e.expectedConformanceRoot)} is not a 64-hex sha256`,
    );
  }
  if (!e.entry.contentRoots.includes(`sha256:${e.expectedConformanceRoot}`)) {
    throw new SessionCatalogError(
      "corrupt-content-root",
      `entry ${e.id}: conformance root sha256:${e.expectedConformanceRoot} is not pinned in entry.contentRoots`,
    );
  }
  // m9i.27: the plan artifact must be pinned + content-verified — a fixed-plan cell whose authored doc is
  // missing or does not hash to its pin is refused here (never silently reproducible), on BOTH the load path
  // and the run path (both flow through assertEntryValid).
  verifyPlanArtifact(e);
}

// ─────────────────────────────────────────────────────────────────────────────
// Load / mirror (djm.8 AC1)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Fail-closed load + validate a manifest (the shipped {@link SESSION_CATALOG} by default): every entry is
 * structurally validated ({@link assertEntryValid}) AND its tape is content-address-verified
 * ({@link resolveTapeEvents}) before any is returned — a corrupt/unsupported/unknown entry throws a typed
 * {@link SessionCatalogError}, never a partial load. Returns the validated {@link SessionCatalogEntry} list.
 */
export function loadSessionCatalog(
  manifest: SessionCatalogManifest = SESSION_CATALOG,
): readonly SessionCatalogEntry[] {
  for (const e of manifest.entries) {
    assertEntryValid(e);
    resolveTapeEvents(e); // content-address the tape (throws corrupt-content-root on a byte mismatch)
  }
  return manifest.entries;
}

/**
 * The platform-mirror view (djm.8 AC1 / AC5): the validated manifest projected to the SAME djm.2
 * `CatalogEntry[]` the managed backend consumes — no rewrite, no second contract. kestrel.markets mirrors
 * exactly this.
 */
export function catalogEntries(
  manifest: SessionCatalogManifest = SESSION_CATALOG,
): readonly CatalogEntry[] {
  return loadSessionCatalog(manifest).map((e) => e.entry);
}

// ─────────────────────────────────────────────────────────────────────────────
// Content-addressed tape resolver (djm.8 AC3/AC4)
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Resolve an entry's shipped generic tape to its {@link BusEvent} stream, content-addressed: the tape bytes
 * are read + re-serialized canonically, and the root MUST equal `entry.provenance.source` — a mismatch (a
 * corrupted or swapped tape) fails closed as `corrupt-content-root`, never a silent load. Pure + deterministic.
 */
export function resolveTapeEvents(e: SessionCatalogEntry): readonly BusEvent[] {
  let events: readonly BusEvent[];
  try {
    events = [...readBus(fixturePath(e.tape))];
  } catch (err) {
    throw new SessionCatalogError("corrupt-content-root", `entry ${e.id}: tape ${e.tape} is unreadable (${msgOf(err)})`);
  }
  const expected = bareRootOf(e.entry.provenance.source);
  const actual = busRoot(events);
  if (expected === null || actual !== expected) {
    throw new SessionCatalogError(
      "corrupt-content-root",
      `entry ${e.id}: tape ${e.tape} content root ${actual} does not match pinned ${e.entry.provenance.source}`,
    );
  }
  return events;
}

// ─────────────────────────────────────────────────────────────────────────────
// Run / verify one entry through djm.4's REAL Session controller (djm.8 AC2)
// ─────────────────────────────────────────────────────────────────────────────

/** Drive ONE entry end-to-end through djm.4's incremental controller: OPEN → author the recipe document →
 * pass every delivered Wake → finalize. IDENTICAL sequence to a bare fixed-plan drive, so a catalog run is
 * byte-identical to an independent `openSession → start → advance → finalize`. */
async function driveEntry(e: SessionCatalogEntry, events: readonly BusEvent[]): Promise<Blotter> {
  const ctrl: SessionController = openSession({
    events,
    config: BACKTEST_CONFIG,
    wakes: e.recipe.wakes,
    fillModel: e.entry.permittedFillModel as FillModelName,
    rUsd: e.recipe.rUsd,
    ...(e.recipe.mandate !== undefined ? { mandate: e.recipe.mandate } : {}),
    ...(e.recipe.brief !== undefined ? { brief: e.recipe.brief } : {}),
    fairTauYears: e.recipe.fairTauYears,
  });
  await ctrl.start();
  let res = await ctrl.advance({ kind: "authored", document: e.recipe.document });
  if (!res.ok) {
    throw new SessionCatalogError("corrupt-content-root", `entry ${e.id}: initial authoring refused (${res.diagnostic})`);
  }
  while (res.ok && res.next !== null) {
    res = await ctrl.advance({ kind: "pass" });
    if (!res.ok) {
      throw new SessionCatalogError("corrupt-content-root", `entry ${e.id}: wake advance refused (${res.diagnostic})`);
    }
  }
  return (await ctrl.finalize()).blotter;
}

/**
 * Run ONE entry LOCALLY end-to-end through the REAL incremental Session path (djm.4's controller over
 * `runSimulateSession` → Bus → Blotter → Grade) and return its graded {@link Blotter} + `conformanceRoot`
 * (= `blotter.session.bus.sha256` = `SimRunId`). Fail-closed FIRST ({@link assertEntryValid} +
 * {@link resolveTapeEvents}), so an unknown fill model / corrupted root / unsupported schema is refused before
 * anything runs. Deterministic — same entry + same engine ⇒ the same root.
 */
export async function runCatalogEntry(
  e: SessionCatalogEntry,
): Promise<{ readonly blotter: Blotter; readonly conformanceRoot: string }> {
  assertEntryValid(e);
  const events = resolveTapeEvents(e);
  const blotter = await driveEntry(e, events);
  return { blotter, conformanceRoot: blotter.session.bus.sha256 };
}

/**
 * Run ONE entry and VERIFY its produced conformance root against the manifest's pinned root, fail-closed: a
 * drift (the produced root differs from `expectedConformanceRoot`, or is not pinned in `entry.contentRoots`)
 * throws a typed `corrupt-content-root`. Returns the verified root on success. This is the reproducibility
 * contract a client checks before trusting a grade.
 *
 * HONEST DRIFT (kestrel-2t0): the pins are golden-like ("as of {@link ENGINE_VERSION}, entry E ⇒ root Y"), so
 * a produced-root mismatch is the SAME signal as an engine change that outran the frozen epoch. Its message is
 * therefore ACTIONABLE — it names ENGINE_VERSION (bump it) and `scripts/catalog/compute-catalog-roots.ts`
 * (regenerate the pins) — rather than a bare hash mismatch that reads as a mysterious CI red. The
 * determinism-only guard (run each entry twice ⇒ identical root, tests/catalog.session.test.ts) covers the
 * churn-robust reproducibility question separately, so this exact check can stay strict without being brittle
 * to an intended engine change.
 */
export async function verifyCatalogEntry(e: SessionCatalogEntry): Promise<string> {
  const { conformanceRoot } = await runCatalogEntry(e);
  if (conformanceRoot !== e.expectedConformanceRoot) {
    throw new SessionCatalogError(
      "corrupt-content-root",
      `entry ${e.id}: produced conformance root ${conformanceRoot} does not match the root pinned as of ` +
        `ENGINE_VERSION ${ENGINE_VERSION} (${e.expectedConformanceRoot}). If this is an INTENDED engine change, ` +
        `bump ENGINE_VERSION in src/catalog/session-catalog.ts and regenerate the pins via ` +
        `'bun scripts/catalog/compute-catalog-roots.ts' (then re-bake the mirror via ` +
        `'bun scripts/sdk/bake-catalog-records.ts'); otherwise the engine is non-reproducible (fail closed).`,
    );
  }
  if (!e.entry.contentRoots.includes(`sha256:${conformanceRoot}`)) {
    throw new SessionCatalogError(
      "corrupt-content-root",
      `entry ${e.id}: produced conformance root sha256:${conformanceRoot} is not pinned in entry.contentRoots`,
    );
  }
  return conformanceRoot;
}

// ─────────────────────────────────────────────────────────────────────────────
// The manifest — generic synthetic Session artifacts (QQQ; no leak)
// ─────────────────────────────────────────────────────────────────────────────

/** The generic Backtest recipe every catalog entry runs: arm one at-the-money long call at OPEN, then pass
 * every Wake (a NO-LLM Backtest — {@link BACKTEST_CONFIG}). Two vantages strictly inside the ~08:30–09:29 ET
 * window; a constant 6.5h fair-tau so `@fair`/`lean` build (a documented sim simplification, RUNTIME §4). */
const RECIPE: SessionRecipe = {
  document: ["PLAN atm-rider budget 0.5R ttl +60m", "  WHEN phase open", "  DO buy 1 atm C @ lean(bid, fair, 0.5)"].join("\n"),
  wakes: [
    { hour: 8, minute: 50 },
    { hour: 9, minute: 10 },
  ],
  rUsd: 10_000,
  fairTauYears: (): number => 6.5 / (365 * 24),
};

/** The ISO-8601 instant the catalog roots were recorded (a fixed, deterministic stamp — never a wall clock). */
const RECORDED_AT = "2026-07-13T00:00:00.000Z";
/** A PUBLIC corpus tier, aligned with the bus `corpus_tier` vocabulary (AC4: never a private/licensed tier). */
const CORPUS_TIER = "public-baseline";
/**
 * The FROZEN engine epoch a faithful replay must satisfy (kestrel-2t0). Deliberately an explicit INTEGER
 * epoch (`kestrel-engine/<N>`), DECOUPLED from {@link PROTOCOL_VERSION} (a wire
 * `major.minor`): the per-entry `expectedConformanceRoot` pins are GOLDEN-LIKE — "as of THIS engine, entry E
 * reproduces to root Y" — so a root-drifting fill/grade/settle change is an EXPLICIT epoch bump HERE, never a
 * silent drift behind an unchanged version string (the pre-2t0 bug: `kestrel-engine/${PROTOCOL_VERSION}` moved
 * the root while the string stayed `kestrel-engine/0.2`). Exported so the pins are honestly inspectable as "as
 * of ENGINE_VERSION X"; the drift path in {@link verifyCatalogEntry} names it. Bump on any change that moves a
 * pinned root, then regenerate the pins via `scripts/catalog/compute-catalog-roots.ts` and re-bake the mirror
 * via `scripts/sdk/bake-catalog-records.ts`.
 */
export const ENGINE_VERSION = "kestrel-engine/4"; // epoch 4 (RESTATED 2026-07-18, kestrel-ukwz / ADR-0037 §2): @fair now derives the forward from put-call parity (F* = K* + (Cmid − Pmid)) instead of assuming forward = spot, threaded into BOTH the IV inversion and the Black-76 price. Epoch 4 additionally carries the fail-closed parity/fair-vs-book guards (kestrel-ku99) and a tau derived from the CONTRACT expiry (kestrel-wcnd); both are INERT on these 0DTE fixtures (expiry == session_date, and the parity band admits every strike here), so neither moves a root on its own. This is BENCHMARK-AFFECTING — it moves every fair-priced fill on a real option book. Only the choppy-1101-strict conformance root drifted (87dbb000…181b7 → 6b523f15…f35d1); spike/trending fills do not touch the forward wedge, so their roots are byte-unchanged. Recomputed via `bun scripts/catalog/compute-catalog-roots.ts` on the corrected engine and re-baked into the mirror via `bun scripts/sdk/bake-catalog-records.ts` — no root hand-edited. Bumped (never a silent drift behind /3) per the "Bump on any change that moves a root" rule below. // epoch 3: the agent-decision RECEIPT producer (kestrel-a57.18) — the driver now writes one CONTROL `propose` per armed PLAN onto the graded session bus (the grade/receipts projector was INERT in production before), so every graded-bus conformance root below moved by EXACTLY that +1 receipt event per PLAN. Schema UNCHANGED (thesis is the honest UNSTATED encoding); tape roots unchanged. Verified: stripping the propose events + re-densifying seq reproduces the epoch-2 roots byte-for-byte. // epoch 2: bus_schema v6 — the graded META advertises the clock-honest deliberation record (ADR-0040 / kestrel-w7la.1); the graded-bus conformance roots below moved with the META bytes (tape roots unchanged)
/**
 * The FROZEN Judge (grade-engine) epoch a faithful replay must satisfy (kestrel-b83, mirroring
 * {@link ENGINE_VERSION}). Deliberately an explicit INTEGER epoch (`kestrel-judge/<N>`), DECOUPLED from
 * {@link PROTOCOL_VERSION} (a wire `major.minor`): a Judge BEHAVIOUR change (grade algorithm,
 * floor/expected/realized channels, gating) is NOT a wire-protocol minor bump, so it MUST be an EXPLICIT
 * epoch bump HERE, never a silent drift behind an unchanged version string (the pre-b83 bug:
 * `kestrel-judge/${PROTOCOL_VERSION}` would let a Judge change land while the string stayed
 * `kestrel-judge/0.2`). Exported so `entry.requirements.judgeVersion` is honestly inspectable as "graded under
 * Judge epoch N". Bump on any Judge-behaviour change, then re-bake the mirror via
 * `scripts/sdk/bake-catalog-records.ts`.
 */
export const JUDGE_VERSION = "kestrel-judge/1";
/** The Backtest View / Rendering identities — content-addressed handles over stable generic descriptors (a
 * NO-LLM Backtest perceives the machine Frame under one fixed identity). */
const VIEW_IDENTITY = `sha256:${sha256(`kestrel.view/backtest/${PROTOCOL_VERSION}`)}`;
const RENDERING_IDENTITY = `sha256:${sha256(`kestrel.rendering/machine-frame/${PROTOCOL_VERSION}`)}`;

/** Assemble one entry from its generic tape + the pinned tape/conformance roots (regenerated by
 * `scripts/catalog/compute-catalog-roots.ts`). Both roots are content-addressed into the embedded djm.2
 * entry's `contentRoots`; the tape root is also its `provenance.source`. */
function makeEntry(args: {
  readonly id: string;
  readonly tape: string;
  readonly fillModel: FillModelName;
  readonly tapeRoot: string;
  readonly conformanceRoot: string;
}): SessionCatalogEntry {
  const entry: CatalogEntry = {
    // The addressing handle rides on the wire entry too (kestrel-33yr.2): the SAME id as the wrapping
    // SessionCatalogEntry, so a client that only ever sees the mirrored `catalogEntries()` projection can
    // open the subject by `entry.id` — the discover→open loop closes with no reach into SESSION_CATALOG.
    id: args.id,
    contentRoots: [`sha256:${args.tapeRoot}`, `sha256:${args.conformanceRoot}`],
    schema: SUPPORTED_SCHEMA,
    corpusTier: CORPUS_TIER,
    requirements: { engineVersion: ENGINE_VERSION, judgeVersion: JUDGE_VERSION },
    permittedFillModel: args.fillModel,
    viewIdentity: VIEW_IDENTITY,
    renderingIdentity: RENDERING_IDENTITY,
    provenance: { source: `sha256:${args.tapeRoot}`, recordedAt: RECORDED_AT },
    // m9i.27: a NO-LLM Backtest (BACKTEST_CONFIG declares no envelope) ⇒ pin the FIXED plan doc by content
    // hash. The doc itself ships content-addressed on `.recipe.document` (resolvePlanDoc recovers it
    // byte-for-byte, re-hashed against this pin).
    inputPin: deriveInputPin(BACKTEST_CONFIG, RECIPE.document),
  };
  return { id: args.id, entry, tape: args.tape, recipe: RECIPE, expectedConformanceRoot: args.conformanceRoot };
}

/**
 * The public Session catalog: one generic synthetic tape per market regime (choppy / spike / trending, all
 * QQQ), graded across both shipped fill models. Each entry RUNS locally to its pinned conformance root through
 * djm.4's controller ({@link runCatalogEntry}) and is mirrorable by the platform as {@link catalogEntries}.
 */
/**
 * The number of decision Wake frames every catalog subject presents — the honest {@link CatalogListing}
 * `frameCount` (kestrel-dnxq). Single-sourced from {@link RECIPE} here (the heavy, engine-side authority) and
 * re-exported so the LIGHT discovery-metadata table (`src/catalog/catalog-listing.ts`, which cannot import the
 * engine-bearing RECIPE) is guarded against drift by tests/sdk.catalog-equal-projection.test.ts (`WAKE_FRAMES`
 * there MUST equal this).
 */
export const RECIPE_WAKE_COUNT = RECIPE.wakes.length;

export const SESSION_CATALOG: SessionCatalogManifest = {
  version: SESSION_CATALOG_VERSION,
  entries: [
    makeEntry({
      id: "choppy-1101-strict",
      tape: "choppy-1101.jsonl",
      fillModel: "strict-cross-v1",
      tapeRoot: "7166b08f3438c8ed6ee23b5e86ae785736f94870207a63051893a00f08b462b1",
      // RESTATED 2026-07-18 (kestrel-ukwz, ADR-0037 §2/Consequences "BENCHMARK-AFFECTING"): the @fair
      // parity-implied forward (F* = K* + (Cmid − Pmid)) moved the strict-cross fill on this choppy tape's
      // fair-priced OTM-put leg, so the graded-bus conformance root drifted 87dbb000…181b7 → 6b523f15…f35d1.
      // Recomputed by `bun scripts/catalog/compute-catalog-roots.ts` on the corrected engine (not hand-edited);
      // tape root unchanged; spike/trending roots unchanged (their fills do not touch the forward wedge).
      conformanceRoot: "6b523f157a0810ae97375e2a913906899e04f4f74f79157054d16829079f35d1",
    }),
    makeEntry({
      id: "spike-1102-maker-fair",
      tape: "spike-1102.jsonl",
      fillModel: "maker-fair-v1",
      tapeRoot: "5b50609e931959aa911b1c576a94d81c0947ff1e0193de8a40054de969baa583",
      conformanceRoot: "20a1ac0ea1264b3f02449e81ee5d8d31afdd6439d6bc2629e7d56cbdf4bffb5b",
    }),
    makeEntry({
      id: "trending-1103-maker-fair",
      tape: "trending-1103.jsonl",
      fillModel: "maker-fair-v1",
      tapeRoot: "2528243e6a4b708bf169beffe6352b95e4db0e3374891e0ab78bfee8a7b871f2",
      conformanceRoot: "fcd23dc97426d2c082e95fc1f86e0ff94fa0c601fd3836c000add10085f494bd",
    }),
  ],
};

/**
 * The catalog DISCOVERY projection (kestrel-dnxq) — the `catalog` op's canonical {@link CatalogListing}[], the
 * offline in-process sample this transport lists. One row per {@link SESSION_CATALOG} entry, built through the
 * SHARED {@link buildCatalogListing} constructor (the SINGLE source of the discovery metadata, in the light
 * `catalog-listing.ts` module — so the LOCAL projection and the light contract server's `GET /catalog` can
 * never drift). Distinct from {@link catalogEntries} (the reproducibility-complete djm.2 surface resolved at
 * open); an id with no listing metadata fails closed there (never a silently blank listing row).
 */
export function catalogListing(): readonly CatalogListing[] {
  return buildCatalogListing(SESSION_CATALOG.entries.map((e) => e.id));
}
