/**
 * # session/paper — the PAPER session driver (kestrel-7o2.12)
 *
 * **The last link.** Every piece of the paper path landed separately — the venue-agnostic
 * {@link import("../adapters/broker.ts").BrokerAdapter Gate seam} + `makeGate` (7o2.4), the safety
 * envelope (7o2.9/.21), the IBKR transport (7o2.5), contracts (7o2.6), feed (7o2.7) and paper order
 * face (7o2.8) — but **nothing started a paper session**: `makeGate("paper", …)` had no production
 * caller, only tests. This module is that caller.
 *
 * ## ONE progression, differing only at the adapters (OSS-ADR-0053)
 * This module is a DRIVER CONFIGURATION over {@link SessionCore} — the same object `runSimSession`
 * (`./sim.ts`) and `runSimulateSession` drive. The per-event pass (clock pin, canonical fold, book
 * fold, engine sweep) lives ONCE, in `SessionCore.step`; a paper session does NOT re-implement it. sim
 * and paper differ at exactly TWO adapters and nothing else (CONTEXT: **Mode** — "same engine, same
 * plans; only the gate differs"):
 *
 *   | | `sim` | `paper` (here) |
 *   |---|---|---|
 *   | feed | a recorded bus file | the IBKR {@link IbkrFeed} — the SAME `BusEvent` union |
 *   | gate | `makeGate("sim", …)` → SimGate | `makeGate("paper", { venue: "ibkr" })` → L0-clamped IBKR face |
 *
 * The gate + the venue-fill feedback are injected into the core through its {@link LiveGateBinding}
 * seam; the feed emits the EXISTING `BusEvent` union (`META`/`TICK SPOT`/`TICK BOOK`/`TICK HEARTBEAT`),
 * which is precisely what `SessionCore.step` already consumes. Because the paper path now shares that
 * pass, the `simulate.*` guard-fixture corpus (driven through the real driver) covers paper too: a
 * wired guard is wired everywhere the progression runs (OSS-ADR-0053 §Consequences).
 *
 * ## Venue substance STAYS in the adapters (OSS-ADR-0053)
 * What remains here is adapter substance, NOT progression logic: the never-live front-matter (mode
 * gate / live-port / handshake account assertion / bounded-limits), the venue seam + its socket
 * lifecycle, the price-anchor/intrinsic the venue's order face reads, the heartbeat + kill-switch +
 * cadence reconciliation pumped each cycle, and the typed {@link PaperSessionRefused} vocabulary.
 *
 * ## NEVER LIVE — structurally, not by policy (AGENTS, ADR-0034 §a/§3)
 * Walls on TWO different things, because they fail differently. Say precisely what enforces this and
 * nothing more — an over-claiming safety string is itself a defect, since it is what an operator reads
 * while deciding whether to trust the system.
 *
 * **On the MODE (what Kestrel intends) — the primary, Kestrel-enforced gate (ADR-0034 §3):**
 *  1. **the mode gate**: {@link openPaperSession} REFUSES any resolved {@link IbkrConfig} whose `mode`
 *     is not `"paper"` — `KESTREL_IBKR_MODE=live` is a LOUD refusal ({@link PaperSessionRefused}
 *     `mode-gate`), never a silent downgrade and never a live route;
 *  2. **the literal**: this module contains exactly ONE `makeGate` call and its mode argument is the
 *     LITERAL `"paper"` — there is no variable to poison, no branch to flip;
 *  3. **the factory**: even if 1+2 were defeated, `makeGate("live", …)` fails closed with
 *     {@link LiveGateRefused} (no human-signed arm exists to hand it, and a {@link PaperArm} can never
 *     authorize live).
 *
 * **On the ENDPOINT (what the socket actually reached) — ADR-0034 §3's second barrier.** Walls 1–3 all
 * guard the mode STRING and the gate OBJECT. A misconfigured port cannot falsify a mode string:
 * `--port 7496` (TWS LIVE) against a live-logged-in gateway is `mode=paper` all the way down while the
 * SOCKET is attached to real money. **The mode was never the leak — the socket was.** So:
 *  4. **the account assertion** (THE barrier): the IBKR transport asserts, on the gateway's OWN
 *     `managedAccounts` handshake and BEFORE any tape is opened / gate is built / order can be
 *     transmitted, that EVERY account the gateway ITSELF REPORTED is a PAPER account (`DU`/`DF`) — the
 *     report must be NON-EMPTY and its EVERY entry paper, so a MIXED report (`DU…,U…`) never passes on
 *     its paper-first entry. It classifies the REPORT, never the operator's pinned CLAIM
 *     (`--account`/`KESTREL_IBKR_ACCOUNT`): ANY reported live `U`-prefixed account — or one that cannot
 *     be classified at all, or an absent report — REFUSES ({@link PaperSessionRefused} `live-account`),
 *     AND, when an account is pinned, the pin must EQUAL one of the gateway's reported (paper) accounts
 *     or it is refused just as loudly (a pinned paper-shaped string can never launder a socket that
 *     reached a different, or a live, account). The socket is CLOSED behind the refusal. Because it
 *     reads the gateway's own report, this holds on ANY port and against ANY pinned claim — it does not
 *     depend on which port you used;
 *  5. **the live-port refusal** (defense-in-depth, secondary): IB's own real-money ports (4001 IB
 *     Gateway live, 7496 TWS live) are refused before a socket is opened (`live-port`), with no
 *     acknowledge-and-proceed escape hatch. A port is a claim about an endpoint, never evidence about
 *     an account — wall 4 (report, not claim) is what actually holds.
 *
 * Live real-money routing (kestrel-7o2.10) is owner-gated and is NOT prepared here.
 *
 * ## REAL LIMITS, TWO CEILINGS (kestrel-7o2.12 AC, ADR-0034 §4)
 * {@link RunPaperOptions.limits} is **REQUIRED** and must be FINITE. This is the point of the bead:
 * `makeGate("paper", …)` with no `limits` falls through to
 * {@link import("../adapters/broker.ts").UNBOUNDED_PAPER_LIMITS}, which makes the seam's L0 clamp a
 * benign no-op and leaves the adapter's WALL 5 as the only real bound. Here ONE config number set is
 * threaded into BOTH layers — the seam clamp (`makeGate`'s `paper.limits`) AND the venue adapter's own
 * WALL 5 (`IbkrBrokerDeps.limits`) — so the two ceilings are defense-in-depth over one source and can
 * never drift. {@link assertBoundedLimits} refuses `Infinity` outright.
 *
 * ## FAIL CLOSED (RUNTIME §8)
 * An unreachable Gateway, a refused socket, an unresolved contract, a missing/absent feed, a dead or
 * stale tape, a degraded transport, or a tripped kill-switch REFUSES LOUDLY with a typed
 * {@link PaperSessionRefused} carrying a logged reason. Never a silent no-op, never a fabricated fill,
 * never a silent default. Defense in depth on the tape: {@link stalenessGatedProvider} ALSO de-arms
 * every market-series read while the feed is degraded, so a plan cannot fire off a dead line even
 * within a cycle of the session-level refusal.
 *
 * ## The injected-time boundary (documented, deliberate — mirrors `./day.ts`)
 * A paper session is a LIVE session: its clock is the venue's tape (`ev.ts`, injected exactly as the
 * sim driver injects the bus's), and the host `sleep`/`now` used to PACE the pump loop are host
 * interaction, not graded computation. Fidelity is `modeled` regardless (CONTEXT: **Fidelity** — sim
 * and paper are both modeled; they differ only by feed).
 *
 * ## Deliberately NOT here (owner decision, kestrel-7o2.12 — stated, not smuggled)
 *  - **the wake/authoring loop.** This driver mirrors {@link runSimSession}: it ARMS a standing
 *    document and drives it. Standing Plans then fire at machine speed — which IS the Plan surface's
 *    own contract (CONTEXT: **Plan** — "fired by the runtime at machine speed; the author is informed
 *    in parallel"). A live wake/authoring loop needs the clock-honest wake machinery (ADR-0040:
 *    deliberation consumes tape time), which is a separate concern; {@link PaperSession} exposes
 *    `step`/`pump`/`bus`, so that layer composes ABOVE this driver rather than being forked into it.
 *  - **BYO-broker credential hardening** (kestrel-7o2.23). IB Gateway auth is a SOCKET to a
 *    locally-running, human-logged-in Gateway (host/port/clientId) — there is no API secret here and
 *    no credential store is invented. {@link PaperVenueSource} is the documented seam if one is ever
 *    needed.
 */

import type { BusEvent, NewBusEvent, OptionQuote, OrderEvent } from "../bus/index.ts";
import { PlanEngine, type Gate, type InstrumentSpec, type OrderIntent } from "../engine/index.ts";
import { CanonicalState, isUnknown } from "../series/index.ts";
import type { KestrelNode, Module } from "../lang/index.ts";
import { intrinsic } from "../fair/index.ts";
import {
  makeGate,
  positionKeyOf,
  type PositionSnapshot,
  type RiskLimits,
} from "../adapters/broker.ts";
// The ONE per-event progression (OSS-ADR-0053). `SessionCore` owns the arm/step pass — the clock pin,
// the canonical fold, the book fold, the engine sweep — and a paper session drives it exactly as the
// sim/day/simulate drivers do, differing ONLY at the Gate seam ({@link LiveGateBinding}) and the feed
// source. `requireMeta`/`resolveSpecs` are the sim driver's own PURE header helpers, reused verbatim so
// a paper session reads its META header by exactly the rule a sim session does.
import { SessionCore, requireMeta, resolveSpecs, type LiveGateBinding, type LiveGateWiring } from "./sim.ts";
// TYPE-ONLY, deliberately (mirrors `adapters/broker.ts`'s own discipline): the venue's socket
// transport and its `@stoqey/ib` dependency must never land on this module's static graph, so the
// default composition is reached through a lazy `import()` inside {@link ibkrPaperVenue}.
import type {
  IbkrBroker,
  IbkrBrokerDeps,
  PriceAnchor,
  QuoteFreshness,
} from "../adapters/broker/ibkr/broker.ts";
import type { IbkrConfig, IbkrConfigInput } from "../adapters/broker/ibkr/config.ts";
import type { IbkrFeed, FeedWatermark } from "../adapters/broker/ibkr/feed.ts";
import type { BrokerStatus, IbkrTransportDeps } from "../adapters/broker/ibkr/transport.ts";

// ─────────────────────────────────────────────────────────────────────────────
// Typed, LOUD refusals
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Why a paper session refused (kestrel-7o2.12) — a CLOSED, typed vocabulary a caller branches on and
 * logs before STANDing DOWN. There is deliberately no "degrade and carry on" member: every one of
 * these ends the session.
 *
 * - `mode-gate`          — the resolved config is not `paper`. The driver is paper-only by construction.
 * - `live-port`          — the endpoint names one of IB's own REAL-MONEY API ports (4001 IB Gateway
 *   live, 7496 TWS live). Defense-in-depth, refused before a socket is opened; there is deliberately
 *   no acknowledge-and-proceed escape hatch — this is the PAPER verb, and it has no legitimate reason
 *   to aim at a live port. NOT the real barrier: a live gateway can listen anywhere (see `live-account`).
 * - `live-account`       — THE BARRIER (ADR-0034 §3). Classifies EVERY account the gateway ITSELF
 *   REPORTED (the full `managedAccounts` list, never the pinned claim). Refused unless the report is
 *   NON-EMPTY and EVERY reported account is a PAPER account (`DU`/`DF`): ANY live `U`-prefixed account,
 *   ANY account that cannot be classified at all, or an absent/empty report is refused just as loudly
 *   (never assume paper; a MIXED report `DU…,U…` does not pass on its paper-first entry) — OR when a
 *   pinned `--account` does not EQUAL ANY of the gateway's reported accounts (a pinned paper-shaped
 *   string can never vouch for a socket that reached a different/live account). Raised AFTER the
 *   handshake and BEFORE any tape/gate/order exists, and the socket is CLOSED behind it.
 * - `limits-absent`      — no {@link RiskLimits}, or an UNBOUNDED one. An unbounded paper gate makes the
 *   seam's L0 clamp a no-op; a session is never run without a real ceiling.
 * - `documents-absent`   — nothing to arm. A session with no standing document has no judgment in it.
 * - `connect-failed`     — the IB Gateway is unreachable / the socket was refused / the handshake lapsed.
 * - `contract-unresolved`— a Kestrel identity did not map onto exactly ONE venue contract.
 * - `feed-absent`        — the tape never opened: the underlier subscription is not live.
 * - `feed-stale`         — the tape DIED mid-session (the underlier line is stale/frozen/dark).
 * - `transport-degraded` — the socket dropped / lost its heartbeat mid-session.
 * - `killed`             — the kill-switch tripped (an L0 halt, or a reconciliation break).
 * - `order-refused`      — a wall REFUSED an order the armed judgment wanted to place (the seam's L0
 *   clamp, or one of the venue face's five walls: never-naked, intrinsic floor, price anchor, contract,
 *   budget). NOTHING was transmitted — that part is the system working. The session ENDS anyway; see
 *   {@link standDownOnRefusal} for why that is the conservative call and not an over-reaction.
 */
export type PaperSessionFailure =
  | "mode-gate"
  | "live-port"
  | "live-account"
  | "limits-absent"
  | "documents-absent"
  | "connect-failed"
  | "contract-unresolved"
  | "feed-absent"
  | "feed-stale"
  | "transport-degraded"
  | "killed"
  | "order-refused";

/**
 * The LOUD, typed, fail-closed refusal of a paper session (kestrel-7o2.12). A DISTINCT class (never a
 * bare `Error`) so a caller catches a session refusal specifically and can never mistake it for an
 * unrelated fault — and so a refusal can NEVER be a silent no-op. Carries the typed
 * {@link PaperSessionFailure} and a secret-free {@link reason} (the account id is redacted upstream by
 * `describeIbkrConfig`; nothing here ever composes a raw account into a message).
 */
export class PaperSessionRefused extends Error {
  override readonly name = "PaperSessionRefused";
  readonly failure: PaperSessionFailure;
  /** The secret-free reason, logged verbatim on the STAND_DOWN path. */
  readonly reason: string;
  constructor(failure: PaperSessionFailure, reason: string, options: { cause?: unknown } = {}) {
    super(
      `paper session refused [${failure}]: ${reason} (fail-closed; STAND_DOWN)`,
      options.cause === undefined ? undefined : { cause: options.cause },
    );
    this.failure = failure;
    this.reason = reason;
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// The venue seam
// ─────────────────────────────────────────────────────────────────────────────

/** What the driver asks a venue to put a tape on. Pure data — no clock, no socket, no credential. */
export interface PaperVenueRequest {
  /** The underlier symbol (generic tickers only in anything shipped — ARCHITECTURE §7). */
  readonly instrument: string;
  /** The session header's opaque calendar token (`YYYY-MM-DD`). */
  readonly sessionDate: string;
  /** The chain expiry (`YYYYMMDD`). Absent ⇒ an equity-only tape; an expiry is NEVER guessed. */
  readonly expiry: string | undefined;
  /**
   * The OBSERVED underlier price the strike window centres on — WHERE THE MONEY IS. REQUIRED whenever
   * {@link expiry} is present (kestrel-7o2.19): the contract layer refuses a centre-less chain slice.
   * It is an INJECTED observation (the operator's `--spot`), which is exactly what determinism at the
   * edge means; the driver never synthesizes one out of a quote.
   */
  readonly spot: number | undefined;
  /** LISTED strikes each side of the ATM one. Absent ⇒ the surface-window default. */
  readonly halfWidth: number | undefined;
  /** Time to expiry in YEARS for `@fair` — INJECTED, never derived from a clock (RUNTIME §0). */
  readonly tauYears: number | undefined;
  /** IB market-data flavour (1 real-time, 3 delayed). Absent ⇒ the gateway's own default is left alone. */
  readonly marketDataType: number | undefined;
  /** The injected host clock (epoch ms). */
  readonly now: () => number;
  /** Redacted-diagnostic sink. Receives only already-redacted strings. */
  readonly log: (line: string) => void;
}

/**
 * The engine-side deps the driver hands the venue's order face — {@link IbkrBrokerDeps} MINUS the three
 * the venue itself owns (its `client`, its resolved `contracts`, and its gateway-issued `nextOrderId`
 * sequence, which is never ours to invent).
 */
export type PaperBrokerRequest = Omit<IbkrBrokerDeps, "client" | "contracts" | "nextOrderId">;

/** One opened venue session: a tape, an order face, and the socket's health. */
export interface PaperVenueSession {
  /** The venue's tape — a {@link IbkrFeed}, i.e. a `FeedSource` over the EXISTING `BusEvent` union. */
  readonly feed: IbkrFeed;
  /** Build the venue's PAPER order face, once the driver's engine-side deps exist (they cannot exist
   * before the tape does — the engine prices against the tape). */
  broker(req: PaperBrokerRequest): IbkrBroker;
  /** The socket's typed status (the account is redacted). `degraded` is the STAND_DOWN signal. */
  status(): BrokerStatus;
  /** Beat the socket's heartbeat watchdog (host-paced, off the graded path). */
  pulse(): void;
  /** Close the tape and detach every listener. Idempotent. */
  close(): void;
}

/**
 * How the driver reaches a venue (kestrel-7o2.12) — the ONE seam between the deterministic driver and
 * a socket. The default is {@link ibkrPaperVenue} (the real IB Gateway); a test injects a fake so the
 * whole driver runs offline, with no gateway and no real money.
 *
 * MODE-LOCKED BY CONTRACT: the {@link IbkrConfig} handed to {@link open} is ALWAYS `mode: "paper"` —
 * {@link openPaperSession} refuses anything else BEFORE this is ever called, and the IBKR transport
 * refuses `live` again on its own. An implementation may rely on that and must never widen it.
 */
export interface PaperVenueSource {
  open(cfg: IbkrConfig, req: PaperVenueRequest): Promise<PaperVenueSession>;
}

// ─────────────────────────────────────────────────────────────────────────────
// The session bus
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The session's ONE Bus (CONTEXT: **Bus**) — append-only, monotonic `seq`, the audit trail and the
 * grading corpus at once. Both the venue's tape events and the engine's own emissions land here in
 * ARRIVAL order under ONE `seq` space: the feed stamps a `seq` in its own space, so it is re-stamped
 * on append (two `seq` spaces on one bus is not a bus).
 */
export class PaperBus {
  readonly events: BusEvent[] = [];
  #seq = 0;

  /** Append one event, stamping the session's own monotonic `seq`, and return the stamped event. */
  append(ev: BusEvent | NewBusEvent): BusEvent {
    const { seq: _venueSeq, ...rest } = ev as NewBusEvent & { seq?: number };
    const stamped = { seq: this.#seq++, ...rest } as BusEvent;
    this.events.push(stamped);
    return stamped;
  }
}

// ─────────────────────────────────────────────────────────────────────────────
// Options / result
// ─────────────────────────────────────────────────────────────────────────────

/** The host-interaction seam — the ONLY wall-clock/sleep/stderr touch points, all off the graded fold
 * (see the header's injected-time boundary). Mirrors `DayHooks`. */
export interface PaperHooks {
  /** Park the HOST between pump cycles (NOT tape time). */
  sleep(ms: number): Promise<void>;
  /** HOST wall clock (ms) — the feed's `now`, and the pump loop's pacing. NOT a graded input. */
  now(): number;
  /** Announce host status lines — a refusal reason, a degraded tape. Never on stdout (a report owns
   * that), never graded. Omitted ⇒ the real `process.stderr`. */
  notify(line: string): void;
}

const DEFAULT_HOOKS: PaperHooks = {
  sleep: (ms) => new Promise<void>((r) => setTimeout(r, ms)),
  now: () => Date.now(),
  notify: (line) => void process.stderr.write(line + "\n"),
};

/** How many pump cycles a session runs for, and how long the host parks between them. */
export const DEFAULT_PUMP_MS = 1_000;

/** Options for {@link openPaperSession} / {@link runPaperSession}. */
export interface RunPaperOptions {
  /** The underlier symbol (generic tickers only — ARCHITECTURE §7). */
  readonly instrument: string;
  /** The session header's calendar token (`YYYY-MM-DD`). */
  readonly sessionDate: string;
  /** The standing documents to arm — already-parsed Kestrel nodes (a bare statement or a full module;
   * both are normalized), EXACTLY as {@link RunSimOptions.documents}: the caller owns the parse, so a
   * parse escape STANDs DOWN before a socket is ever opened. At least one is required. */
  readonly documents: readonly KestrelNode[];
  /**
   * The REAL L0 ceilings, from config. **REQUIRED and FINITE** — threaded into BOTH the seam clamp and
   * the venue adapter's WALL 5. There is deliberately no default: a defaulted risk ceiling is exactly
   * the silent default AGENTS.md forbids.
   */
  readonly limits: RiskLimits;
  /** The bounded-risk budget in dollars (`size × max_loss ≤ budget`). Absent ⇒ `limits.maxNotionalUsd`. */
  readonly budgetUsd?: number | undefined;
  /** Dollar value of 1R. */
  readonly rUsd: number;
  /** IB Gateway channel: host/port/clientId/account. Absent fields fall through to env then the
   * publication-safe defaults (`127.0.0.1:4002`, IB Gateway PAPER). */
  readonly gateway?: IbkrConfigInput | undefined;
  /** The chain expiry (`YYYYMMDD`). Absent ⇒ an equity-only tape. */
  readonly expiry?: string | undefined;
  /** The OBSERVED spot the strike window centres on — required with {@link expiry} (kestrel-7o2.19). */
  readonly spot?: number | undefined;
  /** LISTED strikes each side of ATM. */
  readonly halfWidth?: number | undefined;
  /** Time to expiry in YEARS for `@fair` — injected, never off a clock. */
  readonly tauYears?: number | undefined;
  /**
   * The operator's DECLARATION that the gateway serves REAL-TIME market data (asks IB for type 1). It
   * is an ASSERTION, never an inference — exactly like `day --no-author`. Absent ⇒ the driver stamps
   * every {@link PriceAnchor} `freshness: "unknown"`, and the order face's price-anchor wall then
   * REFUSES every order (fail-closed: an anchor whose provenance we cannot establish is not provenance).
   * A paper session with this absent still PERCEIVES; it just cannot price.
   */
  readonly liveMarketData?: boolean | undefined;
  /** Instrument specs override (else derived from the feed's META). */
  readonly instruments?: readonly InstrumentSpec[] | undefined;
  /** Reconciliation tolerance (absolute qty per key). Absent ⇒ `0`. */
  readonly tolerance?: number | undefined;
  /** How many pump cycles {@link runPaperSession} drives. Required — an unbounded live loop is not a
   * session, it is a daemon. */
  readonly cycles: number;
  /** Host park between pump cycles. Default {@link DEFAULT_PUMP_MS}. */
  readonly pumpMs?: number | undefined;
  /** The venue seam (test injection point). Default: the real IB Gateway ({@link ibkrPaperVenue}). */
  readonly venue?: PaperVenueSource | undefined;
  /** Transport-level injection for the DEFAULT venue (a fake `makeClient`, a manual scheduler). */
  readonly transport?: IbkrTransportDeps | undefined;
  /** Host-interaction hooks (test seam). Spread OVER {@link DEFAULT_HOOKS} — an omitted field inherits
   * the REAL host, exactly as `DayHooks` documents. */
  readonly hooks?: Partial<PaperHooks> | undefined;
}

/** The live paper Session — the runtime object CONTEXT names: `{ driver, clock, gate, bus, mode }`. */
export interface PaperSession {
  /** CONTEXT: **Mode**. A literal — this object can never describe anything else. */
  readonly mode: "paper";
  /** The L0-clamped paper gate from `makeGate("paper", { venue: "ibkr" })`. NEVER a live gate. */
  readonly gate: Gate;
  /** The venue's paper order face beneath the clamp (its ledger/reconcile/kill-switch surface). */
  readonly broker: IbkrBroker;
  readonly feed: IbkrFeed;
  /** The session's single truth (CONTEXT: **Bus**) — the {@link SessionCore} emitted stream, into which
   * BOTH the feed's tape events and the engine's own emissions land in arrival order under one `seq`
   * space. A read-only projection; the driver never appends to it directly. */
  readonly bus: { readonly events: readonly BusEvent[] };
  readonly engine: PlanEngine;
  readonly state: CanonicalState;
  /** The REAL ceilings this session is bounded by — the SAME object threaded into both L0 layers. */
  readonly limits: RiskLimits;
  /** Fold ONE tape event through {@link SessionCore.step} — pin the venue clock, append to the bus,
   * fold canonical state, sweep, then feed any venue fills back (post-sweep, the broker ordering). */
  step(ev: BusEvent): void;
  /** One live cycle: check the socket + the kill-switch + the tape's watermark (each refuses LOUDLY),
   * then pump the feed and step everything it drained. */
  pump(): void;
  /** The tape's staleness watermark — freshness is READ, never inferred from canonical state. */
  watermark(): FeedWatermark;
  close(): void;
}

/** What a completed paper session yields. */
export interface PaperSessionResult {
  /** The session's Bus (CONTEXT: **Bus**) — the audit trail a Blotter projects from. */
  readonly events: readonly BusEvent[];
  /** The venue's per-order reconciliation ledger (engine expectation beside broker truth). */
  readonly ledger: ReturnType<IbkrBroker["ledger"]>;
  /** The BROKER's own authoritative closing positions (never the engine's expectation). */
  readonly positions: PositionSnapshot;
  /** The tape's closing watermark. */
  readonly watermark: FeedWatermark;
  /** How many cycles actually ran (a refusal ends the session early — and throws). */
  readonly cycles: number;
}

// ─────────────────────────────────────────────────────────────────────────────
// Guards
// ─────────────────────────────────────────────────────────────────────────────

/**
 * REFUSE an absent/unbounded ceiling (kestrel-7o2.12 AC#2). `makeGate("paper", …)` without `limits`
 * silently falls through to `UNBOUNDED_PAPER_LIMITS` — every ceiling `+Infinity` — which composes the
 * clamp but makes its L0 check a benign no-op. That is precisely the hole this bead closes, so a
 * non-finite ceiling is refused HERE, before a gate is ever built.
 */
export function assertBoundedLimits(limits: RiskLimits | undefined): RiskLimits {
  if (limits === undefined) {
    throw new PaperSessionRefused(
      "limits-absent",
      "a paper session requires explicit RiskLimits — an absent ceiling falls through to UNBOUNDED_PAPER_LIMITS, which makes the seam's L0 clamp a benign no-op",
    );
  }
  for (const [name, v] of [
    ["maxOrderQty", limits.maxOrderQty],
    ["maxPositionQty", limits.maxPositionQty],
    ["maxNotionalUsd", limits.maxNotionalUsd],
  ] as const) {
    if (!Number.isFinite(v) || v <= 0) {
      throw new PaperSessionRefused(
        "limits-absent",
        `RiskLimits.${name} must be a FINITE positive ceiling (got ${String(v)}) — an unbounded ceiling is not a ceiling`,
      );
    }
  }
  return limits;
}

// ─────────────────────────────────────────────────────────────────────────────
// The default venue composition — lazily reached, so no socket lands on this graph
// ─────────────────────────────────────────────────────────────────────────────

/**
 * The REAL IB Gateway venue (kestrel-7o2.12). Every `@stoqey/ib`-bound module is reached through a
 * LAZY `import()` inside `open`, exactly as `adapters/broker.ts` keeps the venue registry behind a
 * thunk: this driver's static graph therefore carries no socket, and importing `session/paper.ts`
 * never pulls the IB client.
 */
export function ibkrPaperVenue(transportDeps?: IbkrTransportDeps): PaperVenueSource {
  return {
    async open(cfg: IbkrConfig, req: PaperVenueRequest): Promise<PaperVenueSession> {
      const [{ IbkrTransport }, { contractClientOf }, feedMod, brokerMod, dryrun] = await Promise.all([
        import("../adapters/broker/ibkr/transport.ts"),
        import("../adapters/broker/ibkr/contract.ts"),
        import("../adapters/broker/ibkr/feed.ts"),
        import("../adapters/broker/ibkr/broker.ts"),
        import("../adapters/broker/ibkr/order-dryrun.ts"),
      ]);

      const transport = new IbkrTransport(cfg, { log: req.log, ...transportDeps });
      // A refused socket / a lapsed handshake rejects with the transport's own typed
      // IbkrConnectionError; `openPaperSession` maps it onto the typed session refusal (never a hang,
      // never a silent no-op).
      await transport.connect();

      const contractDeps = {
        client: contractClientOf(transport),
        ...(cfg.account === undefined ? {} : { account: cfg.account }),
        log: req.log,
      };
      // The 7o2.6 contract layer: the gateway's OWN definitions, never hand-rolled and never guessed.
      // An ambiguous/unresolvable identity raises IbkrContractError → STAND_DOWN. A chain slice with no
      // observed spot is refused here too (kestrel-7o2.19: no money, no window).
      const { underlier, legs } = await feedMod.resolveFeedContracts(
        {
          symbol: req.instrument,
          ...(req.expiry === undefined ? {} : { expiry: req.expiry }),
          ...(req.spot === undefined ? {} : { spot: req.spot }),
          ...(req.halfWidth === undefined ? {} : { halfWidth: req.halfWidth }),
        },
        contractDeps,
      );

      const feed = feedMod.ibkrFeed(
        {
          instrument: req.instrument,
          sessionDate: req.sessionDate,
          // MODE-LOCKED: `cfg.mode` is `"paper"` — openPaperSession refused anything else before this
          // ran, and the transport refuses `live` again on its own.
          mode: cfg.mode,
          ...(req.expiry === undefined ? {} : { expiry: req.expiry }),
          ...(req.tauYears === undefined ? {} : { tauYears: req.tauYears }),
          ...(req.marketDataType === undefined ? {} : { marketDataType: req.marketDataType }),
          ...(cfg.account === undefined ? {} : { account: cfg.account }),
        },
        {
          client: feedMod.feedClientOf(transport),
          underlier,
          legs,
          now: req.now,
          log: req.log,
        },
      );

      // The contract book is the ORDER path's pre-resolved set (WALL 2), so it carries only TRADABLE
      // contracts. An INDEX underlier (kestrel-7o2.24 — a cash-settled index option's spot, a
      // published NUMBER) is quotable but not tradable, so it is not admitted: a spot leg against it
      // finds no pre-resolved contract and WALL 2 refuses, which is the honest answer to "buy the
      // index" and needs no new guard of its own. Today this narrowing is a no-op — nothing here
      // states `underlierSecType`, so `resolveUnderlier` asks for `STK` and the underlier is always
      // an equity — but the type keeps it true if that ever changes.
      const tradableUnderlier = underlier.kind === "equity" ? [underlier] : [];
      const contracts = brokerMod.contractBook([...tradableUnderlier, ...legs]);
      const orderClient = brokerMod.orderClientOf(transport);
      // The id sequence is the GATEWAY's (`nextValidId`), never ours to invent and never an RNG; with
      // no seed issued, a transmit FAILS CLOSED rather than guessing an id that could collide.
      const nextOrderId = dryrun.mintOrderId(orderClient);

      return {
        feed,
        broker: (r: PaperBrokerRequest): IbkrBroker =>
          // `ibkrBroker` REFUSES `mode: "live"` at construction — a fourth, independent never-live wall.
          brokerMod.ibkrBroker(cfg, { ...r, client: orderClient, contracts, nextOrderId }),
        status: () => transport.status(),
        pulse: () => transport.pulse(),
        close: () => {
          feed.close();
          transport.disconnect();
        },
      };
    },
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// The driver
// ─────────────────────────────────────────────────────────────────────────────

/** Normalize an authored node to a module — the same one-line rule the sim driver's own `toModule`
 * applies (it is private there; the rule, not a second policy, is what is mirrored). */
function toModule(node: KestrelNode): Module {
  return node.kind === "module" ? node : { kind: "module", imports: [], statements: [node] };
}

/**
 * Open a live paper Session against the venue: connect, resolve contracts, open the tape, build the
 * L0-clamped paper gate, arm the standing documents. Every failure is a LOUD typed
 * {@link PaperSessionRefused}.
 */
export async function openPaperSession(opts: RunPaperOptions): Promise<PaperSession> {
  const hooks: PaperHooks = { ...DEFAULT_HOOKS, ...opts.hooks };
  const log = (line: string): void => hooks.notify(`paper: ${line}`);

  // (1) THE CEILING. Before a socket, before a gate: an unbounded paper session is not run at all.
  const limits = assertBoundedLimits(opts.limits);
  if (opts.documents.length === 0) {
    throw new PaperSessionRefused(
      "documents-absent",
      "a paper session must arm at least one standing document — a session with nothing armed has no judgment in it",
    );
  }

  // (2) THE MODE GATE — never-live wall #1. The config CANNOT select live: a `live` resolution is a
  // LOUD refusal, never a silent downgrade to paper (which would launder an operator's live intent
  // into a paper run they did not ask for) and never a live route.
  const { resolveIbkrConfig, describeIbkrConfig, IBKR_LIVE_PORTS } = await import(
    "../adapters/broker/ibkr/config.ts"
  );
  const cfg = resolveIbkrConfig(opts.gateway ?? {});
  if (cfg.mode !== "paper") {
    throw new PaperSessionRefused(
      "mode-gate",
      // PRECISE, not blanket. The old wording here claimed live "is not reachable from here, by any
      // flag, env var, or config value" — which was FALSE, and falsely reassuring in exactly the
      // string an operator reads while deciding whether to trust this system: `--port 7496` /
      // `KESTREL_IBKR_PORT` IS such a flag, and it reaches a live-logged-in gateway on a `mode=paper`
      // run. Over-claiming in a safety string is itself a defect. So: name what actually enforces it.
      `the paper session driver is PAPER-ONLY by construction (resolved mode=${cfg.mode}) — it can only ever build a PAPER gate. Live real-money routing is owner-gated (kestrel-7o2.10). Four things enforce that, none of which a flag/env/config value can lower: this mode gate; the driver's single gate construction, whose mode argument is the literal "paper" (no variable to poison, no branch to flip); the HANDSHAKE ACCOUNT ASSERTION (a session is refused and its socket closed unless EVERY account the gateway reports is a PAPER account — any non-paper or unclassifiable one, including in a mixed report, refuses, ADR-0034 §3); and the live-port refusal`,
    );
  }
  // (2b) THE PORT POLICY — defense-in-depth, and SECONDARY by construction (ADR-0034 §3: "the
  // broker's paper/live account is defense-in-depth, not the gate"). A port number is a claim about
  // an ENDPOINT, never evidence about an ACCOUNT: a live gateway can be moved to any port at all, so
  // this catches the fat-finger EARLY and cheaply while the account barrier — which classifies EVERY
  // account the gateway reports (report, not claim; every reported account must be paper, and a pinned
  // account must match one of them) — is what
  // catches every case including a nonstandard live port. There is deliberately NO acknowledge-and-
  // proceed escape hatch: this is the `paper` verb, and no legitimate paper session aims at IB's own
  // real-money port.
  const liveListener = IBKR_LIVE_PORTS.get(cfg.port);
  if (liveListener !== undefined) {
    throw new PaperSessionRefused(
      "live-port",
      `port ${cfg.port} is IB's own REAL-MONEY API port (${liveListener}) — a paper session never aims at it, and there is no flag to proceed anyway. Use the PAPER endpoint (IB Gateway paper 4002, TWS paper 7497). Nothing was opened; no socket was ever attempted`,
    );
  }
  log(`opening ${describeIbkrConfig(cfg)} — limits: qty≤${limits.maxOrderQty} pos≤${limits.maxPositionQty} notional≤$${limits.maxNotionalUsd}`);

  // (3) NORMALIZE the already-parsed documents (the caller owned the parse — a parse escape never
  // reaches a socket). Same rule as the sim driver's own `toModule`.
  const modules: Module[] = opts.documents.map(toModule);

  // (4) THE VENUE. A refused socket / an unresolved contract surfaces as a typed refusal, never a hang.
  const venue = opts.venue ?? ibkrPaperVenue(opts.transport);
  let session: PaperVenueSession;
  try {
    session = await venue.open(cfg, {
      instrument: opts.instrument,
      sessionDate: opts.sessionDate,
      expiry: opts.expiry,
      spot: opts.spot,
      halfWidth: opts.halfWidth,
      tauYears: opts.tauYears,
      // Ask for REAL-TIME data only when the operator DECLARED it (see `liveMarketData`); otherwise
      // leave the gateway's own default alone rather than silently downgrading anyone to delayed.
      marketDataType: opts.liveMarketData === true ? 1 : undefined,
      now: hooks.now,
      log,
    });
  } catch (err) {
    throw refusalFor(err, cfg, describeIbkrConfig);
  }

  try {
    return await wireSession(opts, cfg, session, modules, limits, hooks, log);
  } catch (err) {
    // Never leave a socket/tape open behind a refusal.
    session.close();
    throw err instanceof PaperSessionRefused ? err : refusalFor(err, cfg, describeIbkrConfig);
  }
}

/** Map a venue-layer throw onto the driver's typed refusal, PRESERVING the cause. The failure kind is
 * the operator's first clue about which wall they hit, so a connection loss must never surface as a
 * contract problem (the transport's own discipline, mirrored). */
function refusalFor(err: unknown, cfg: IbkrConfig, describe: (c: IbkrConfig) => string): PaperSessionRefused {
  if (err instanceof PaperSessionRefused) return err;
  const e = err as { name?: string; message?: string; failure?: string };
  const where = describe(cfg);
  // THE BARRIER's own refusal, carried through with its OWN kind (ADR-0034 §3). It must NEVER fall
  // through to the `connect-failed` default below: "we reached a REAL account" and "we could not
  // reach the gateway" are opposite facts, and the second would send an operator off to check
  // whether their gateway is running while the actual finding is that it is running, logged into
  // real money, and was refused. The transport already closed that socket.
  if (e?.name === "IbkrConnectionError" && e?.failure === "live-account") {
    return new PaperSessionRefused("live-account", `${e.message ?? "(no message)"}`, { cause: err });
  }
  if (e?.name === "IbkrContractError") {
    return new PaperSessionRefused("contract-unresolved", `${e.message ?? "(no message)"} — ${where}`, { cause: err });
  }
  if (e?.name === "IbkrFeedError") {
    return new PaperSessionRefused("feed-absent", `${e.message ?? "(no message)"} — ${where}`, { cause: err });
  }
  // IbkrConnectionError, and anything else the venue threw on the way up: the gateway is not usable.
  return new PaperSessionRefused(
    "connect-failed",
    `the IB Gateway at ${where} could not be reached or its session could not be established: ${e?.message ?? String(err)}. Is a PAPER IB Gateway running and logged in on that host/port?`,
    { cause: err },
  );
}

/** Wire the tape, the gate, and the engine into one Session. Split out so `openPaperSession` can close
 * the venue on any refusal raised here. */
async function wireSession(
  opts: RunPaperOptions,
  cfg: IbkrConfig,
  venue: PaperVenueSession,
  modules: readonly Module[],
  limits: RiskLimits,
  hooks: PaperHooks,
  log: (line: string) => void,
): Promise<PaperSession> {
  const { feed } = venue;
  const { stalenessGatedProvider } = await import("../adapters/broker/ibkr/feed.ts");
  // The venue registry + the paper venue token — resolved BEFORE the core is constructed, because the
  // gate is built synchronously inside SessionCore's live-gate seam (below). Awaiting here keeps the
  // core's own static graph socket-free (every `@stoqey/ib`-bound module stays behind a lazy import).
  const { installIbkrPaperVenue, IBKR_PAPER_VENUE } = await import("../adapters/broker/ibkr/broker.ts");

  // (5) OPEN THE TAPE. `open` emits META, subscribes every line, and settles — each subscription with
  // its own deadline, so a refusal surfaces typed rather than as silence.
  const opened = await feed.open();
  for (const f of opened.failed) log(`a subscription was REFUSED by the venue: ${f.message}`);
  const underlierSub = feed.subscriptions().find((s) => s.kind === "spot");
  if (underlierSub === undefined || underlierSub.state !== "live") {
    // FAIL CLOSED: no underlier line ⇒ no tape at all. There is no spot to drive canonical state and
    // nothing to quote a book against; a session on a tape that never opened is a fiction.
    throw new PaperSessionRefused(
      "feed-absent",
      `the underlier line for ${opts.instrument} is ${underlierSub?.state ?? "absent"}${
        underlierSub?.error == null ? "" : `: ${underlierSub.error.message}`
      } — there is no tape, so there is no session`,
    );
  }

  const events = [...feed.events()];
  const meta = requireMeta(events);
  const specs = resolveSpecs(meta, opts.instruments);

  // The venue's paper order face + the L0-clamped paper gate are built INSIDE SessionCore's live-gate
  // seam (OSS-ADR-0053) so the ONE engine sweep routes fires through them and their fills land on the
  // session's single bus. `broker`/`gate` are captured here for the returned PaperSession; nothing about
  // the per-event pass is re-implemented — it lives once, in `SessionCore.step`.
  let broker!: IbkrBroker;
  let gate!: Gate;

  /**
   * The live-gate seam (OSS-ADR-0053) — everything paper substitutes for the sim judge, and nothing
   * more. Given the core's own canonical state + folded books, it builds the venue order face and the
   * paper gate, wraps the series provider in the feed-death staleness gate, and hands back the two fill
   * hooks the core's per-event pass calls (fold-expected, and the wall-refusal STAND_DOWN boundary).
   */
  const buildLiveGate = (wiring: LiveGateWiring): LiveGateBinding => {
    const execSpec = wiring.execSpec;
    const num = (v: number | symbol): number | null => (isUnknown(v) ? null : (v as number));

    /** The engine's EXPECTED signed net positions — folded from the ORDER fills IT observed. The
     * broker's own PULL is the authoritative side; this is only ever the expectation the reconciliation
     * compares AGAINST, never a risk input. */
    const expected: Record<string, number> = {};
    const expectedPositions = (): PositionSnapshot => ({ ...expected });

    /**
     * The PRICE ANCHOR the order face's wall reads: the venue's OBSERVED two-sided book for the leg
     * (READ from the core's own folded book — no second book of the driver's own), plus the `@fair` the
     * tape's own surface resolved, plus its trust verdict. Fail-closed at every step — a leg with no
     * carried book, a one-sided/dark quote, an unresolved/tainted fair, a degraded tape, or undeclared
     * data provenance yields NO anchor, and the order face then refuses the order rather than
     * transmitting against a price nothing corroborates.
     */
    const priceAnchorOf = (leg: {
      readonly instrument: string;
      readonly strike?: number;
      readonly right?: Right;
    }): PriceAnchor | undefined => {
      if (leg.strike === undefined || leg.right === undefined) return undefined; // equity: no chain book here
      const chain = wiring.books.get(execSpec.symbol);
      const book = chain?.legs.find((l) => l.strike === leg.strike && l.right === leg.right);
      if (book === undefined || book.bid === null || book.ask === null) return undefined; // dark/one-sided/absent
      const wm = feed.watermark();
      if (wm.health !== "LIVE") return undefined; // a DEGRADED tape never anchors a price
      const fair = feed.fair({ strike: leg.strike, right: leg.right, side: "buy" });
      if (fair.value === null || fair.receipt === null) return undefined; // no fair, no anchor
      return {
        bid: book.bid,
        ask: book.ask,
        fair: fair.value,
        // The receipt VOUCHES only when the surface covers the money and carries no taint (kestrel-ltrf /
        // 7o2.19): a count of liquid strikes is not evidence about an at-the-money price.
        fairTrusted: fair.taint === null && fair.coverage.supportsAtm,
        freshness: quoteFreshness(opts.liveMarketData === true, wm),
      };
    };

    /** The leg's per-contract INTRINSIC in dollars-per-point — computed from CANONICAL state's observed
     * spot (the engine's own truth), never from a quote. UNKNOWN spot ⇒ `undefined` ⇒ a SELL fails
     * closed rather than assuming its floor is satisfied. */
    const intrinsicOf = (leg: {
      readonly instrument: string;
      readonly strike?: number;
      readonly right?: Right;
    }): number | undefined => {
      if (leg.strike === undefined || leg.right === undefined) return undefined;
      const spot = num(wiring.state.spot);
      if (spot === null) return undefined;
      return intrinsic(spot, leg.strike, leg.right);
    };

    // (6) THE ORDER FACE — with the SAME real ceilings the seam clamp gets (defense-in-depth over ONE
    // config, ADR-0034 §4: two independently-proven L0 ceilings that can never drift to different
    // numbers). Its `drain` carries the venue's own ORDER events onto the session's single live bus
    // (the core queues each fill for the post-sweep feedback).
    let drained = 0;
    broker = venue.broker({
      drain: () => {
        for (const ev of broker.events.slice(drained)) wiring.appendVenueEvent(ev);
        drained = broker.events.length;
      },
      limits,
      ...(opts.budgetUsd === undefined ? {} : { budgetUsd: opts.budgetUsd }),
      intrinsicOf,
      priceAnchorOf,
      expectedPositions,
      ...(opts.tolerance === undefined ? {} : { tolerance: opts.tolerance }),
      log,
    });

    // (7) THE GATE. THE ONE `makeGate` CALL IN THIS MODULE, AND ITS MODE IS THE LITERAL `"paper"`
    // (never-live wall #2 — there is no variable here to poison and no branch to flip). The venue is
    // reached through the REGISTRY (`installIbkrPaperVenue` → `venue: "ibkr"`), so the core seam still
    // imports no socket, and `limits` is passed EXPLICITLY so the seam's L0 clamp enforces the
    // operator's REAL ceilings instead of falling through to UNBOUNDED_PAPER_LIMITS. `makeGate` threads
    // the venue's own kill-switch and TRUE per-contract multiplier into the clamp structurally.
    installIbkrPaperVenue(broker);
    gate = makeGate("paper", {
      venue: IBKR_PAPER_VENUE,
      paper: {
        limits,
        ...(opts.tolerance === undefined ? {} : { tolerance: opts.tolerance }),
        positions: expectedPositions,
      },
    });

    const foldExpected = (ev: OrderEvent): void => {
      if (ev.type !== "fill") return;
      const key = positionKeyOf(ev);
      const signed = ev.side === "buy" ? ev.qty : -ev.qty;
      expected[key] = (expected[key] ?? 0) + signed;
    };

    return {
      gate: gate as Gate & { now: number },
      // (8) THE FEED-DEATH GATE (defense in depth, kestrel-7o2.7/rs4/8kc). Canonical state folds every
      // SPOT and then KNOWS that price forever — a frozen line looks perfectly healthy from inside it.
      // The gated provider makes every MARKET series read UNKNOWN while the tape is DEGRADED, so a
      // dependent trigger CANNOT fire off a dead line and de-arms with a logged reason (RUNTIME §3/§8).
      // The session ALSO refuses outright on a dead underlier (see `pump`) — two independent walls.
      wrapSeriesProvider: (base) => stalenessGatedProvider(base, feed, { log }),
      // Fold each venue fill into the expected-position ledger (the reconciliation input) BEFORE the
      // core feeds it back to the engine (the broker fills-after-sweep ordering the core owns).
      onVenueFill: (f) => foldExpected(f),
      // A wall REFUSING a fired order becomes a LOUD, typed session STAND_DOWN — never a swallowed
      // throw that would leave the engine's plan state mid-fire and possibly disagreeing with the venue.
      guardSweep: (apply) => standDownOnRefusal(apply, log),
    };
  };

  const core = new SessionCore({
    meta,
    specs,
    // The sim fill engine is INERT on the paper path — orders route to the venue gate, never rest in it
    // — so the model name only has to be legal; nothing is graded under it (no `settle`, no report).
    fillModel: "strict-cross-v1",
    rUsd: opts.rUsd,
    // The engine's own `@fair` is unbuildable on the paper path (the FEED carries the `@fair` the order
    // face reads, via `priceAnchorOf`); a `null` tau keeps it so, matching the pre-unification engine.
    fairTauYears: () => null,
    firstTs: events[0]?.ts ?? hooks.now(),
    liveGate: buildLiveGate,
  });

  // (9) THE OPEN, in the one order a Bus permits. The tape's own opening events (META + any first
  // prints) fold FIRST, so the session's Bus OPENS WITH ITS META — a tape whose header does not lead it
  // is not a bus, and this session's Bus is one stream (the feed's events and the engine's own emissions
  // share its `seq` space, so arming before the header would put a PLAN event ahead of it). The document
  // then arms AT the open, which is also when an author actually arms one. A per-statement
  // `unknown-series` refusal (OSS-ADR-0045) / a `plan-name-in-use` (F4) throws from `core.arm` — the
  // SAME whole-document fail-loud boundary the sim driver keeps.
  for (const ev of events) core.step(ev);
  for (const m of modules) core.arm(m);
  log(`armed ${modules.length} document(s) on ${opts.instrument} — the tape is live (${feed.subscriptions().length} subscription(s))`);

  // The session's single Bus (CONTEXT: **Bus**) — the core's emitted stream, into which BOTH the feed's
  // tape events and the engine's own emissions land in arrival order under one `seq` space.
  const bus = {
    get events(): readonly BusEvent[] {
      return core.emitted.events;
    },
  };

  const pump = (): void => {
    // (a) THE SOCKET. A dropped socket / a lost heartbeat is a STAND_DOWN, never a quiet stall.
    venue.pulse();
    const st = venue.status();
    if (st.degraded) {
      throw new PaperSessionRefused("transport-degraded", `the IB Gateway session degraded: ${st.reason ?? "(no reason)"}`);
    }
    // (b) THE KILL-SWITCH. A trip in EITHER L0 layer (the seam clamp's operator halt, or the venue
    // face's own at-observation never-naked / reconciliation trip) halts the WHOLE path — makeGate
    // threads ONE switch through both, so reading the face's is reading the clamp's.
    if (broker.killSwitch.tripped) {
      throw new PaperSessionRefused("killed", `the kill-switch is TRIPPED: ${broker.killSwitch.reason ?? "(no reason)"}`);
    }
    // (c) THE TAPE. A DEAD underlier line means the session has no coordinate left: every market series
    // hangs off it, so its death taints all of them. Refuse LOUDLY — a frozen line re-printing a
    // price the market left behind is exactly the 2025-04-09 failure mode.
    const wm = feed.watermark();
    if (wm.underlierStale) {
      throw new PaperSessionRefused(
        "feed-stale",
        `the ${opts.instrument} underlier line is STALE/DEAD (${wm.reason ?? "no value change within the staleness window"}) — a frozen tape is not a live tape, and nothing may be priced or fired off it`,
      );
    }
    feed.pump();
    for (const ev of feed.drain()) core.step(ev);
    // (d) THE CADENCE RECONCILE (kestrel-7o2.10, A3). Broker truth vs the local ledger was compared ONLY
    // at two discrete moments — an at-observation trip on a pushed break, and `runPaperSession`'s CLOSING
    // reconcile — so any divergence the push did not itself surface (a fill pushed against a key the
    // engine never originated, an engine order the venue never terminated) could sit UNDETECTED for the
    // entire session and be discovered only once it was too late to act on. Reconciling here bounds the
    // drift window to ONE cycle: the comparison runs AFTER the tape folds, so it reads the freshest
    // expected-vs-actual pair, and the switch is re-read IMMEDIATELY so a break surfaces in the very
    // cycle it is detected rather than one cycle later. Deterministic — cycle-counted, never wall-clocked
    // (RUNTIME §0) — and cheap: the face's `positions()` is an in-memory fold of the push already
    // subscribed at construction, so this adds no socket round-trip.
    broker.reconcile();
    if (broker.killSwitch.tripped) {
      throw new PaperSessionRefused(
        "killed",
        `the kill-switch TRIPPED on the cycle reconcile: ${broker.killSwitch.reason ?? "(no reason)"}`,
      );
    }
  };

  return {
    mode: "paper",
    gate,
    broker,
    feed,
    bus,
    engine: core.engine,
    state: core.state,
    limits,
    step: (ev: BusEvent): void => core.step(ev),
    pump,
    watermark: () => feed.watermark(),
    close: () => venue.close(),
  };
}

/** The typed order refusals a WALL raises on the submit path — the seam's L0 clamp (`ClampRefused`)
 * and the venue face's own five walls (`IbkrOrderRefused`). Matched STRUCTURALLY by name, not with
 * `instanceof`: the venue's class lives behind this module's lazy `import()` boundary, and an
 * `instanceof` across it is not a contract worth betting a fail-closed path on. */
const ORDER_REFUSAL_NAMES: ReadonlySet<string> = new Set(["ClampRefused", "IbkrOrderRefused"]);

/**
 * A wall REFUSED an order. Convert it into a LOUD, typed session STAND_DOWN.
 *
 * Why the session ENDS rather than resumes — the conservative call, stated rather than smuggled
 * (kestrel-7o2.12). A refusal itself is the system WORKING: nothing was transmitted, which is the whole
 * point of the walls. But the refusal reaches us as a THROW out of `PlanEngine.onEvent` (the Gate seam
 * has no non-throwing "refused" reply, and `engine/plans.ts` is out of scope for this bead), so the
 * engine's plan state at that instant is mid-fire and may now disagree with the venue about what is
 * working. Swallowing the throw to keep the loop alive would be exactly the silent no-op AGENTS.md
 * forbids — running an engine whose book may not match the venue's. So: log the wall's own typed reason
 * verbatim, then STAND DOWN.
 *
 * FOLLOW-UP (a real seam, not a shrug): routine live refusals (an option leg with no book yet) SHOULD
 * de-arm the one plan and let the session continue. That needs the Gate seam to be able to report a
 * refusal without throwing — an `engine/plans.ts` change, deliberately not made here.
 */
function standDownOnRefusal(fn: () => void, log: (line: string) => void): void {
  try {
    fn();
  } catch (err) {
    const e = err as { name?: string; message?: string };
    if (e?.name !== undefined && ORDER_REFUSAL_NAMES.has(e.name)) {
      log(`an order was REFUSED at a wall and NOTHING was transmitted: ${e.message ?? "(no message)"}`);
      throw new PaperSessionRefused("order-refused", e.message ?? "(no message)", { cause: err });
    }
    throw err;
  }
}

/**
 * The provenance stamped on a {@link PriceAnchor}. `live` ONLY when the operator DECLARED real-time
 * data AND the tape's own watermark vouches for it. Otherwise `unknown` — and `unknown` is refused by
 * the order face's wall just as loudly as `delayed`, because provenance we cannot establish is not
 * provenance.
 *
 * KNOWN, DOCUMENTED GAP (a seam, not a silence): IB SUBSTITUTES delayed data rather than refusing when
 * an account lacks a real-time subscription, and the 7o2.7 feed folds the delayed tick ids (66–68) onto
 * the same fields without surfacing per-line freshness. So the declaration is an operator ASSERTION —
 * exactly the `day --no-author` idiom — and the DEFAULT (absent) is refuse-to-price, never assume-live.
 */
function quoteFreshness(declaredLive: boolean, wm: FeedWatermark): QuoteFreshness {
  if (!declaredLive) return "unknown";
  return wm.health === "LIVE" ? "live" : "unknown";
}

/** `Right` is re-declared locally as a type alias to keep this module's import surface narrow. */
type Right = NonNullable<OptionQuote["right"]>;

/**
 * Run a paper session to completion (kestrel-7o2.12): open it, then drive `cycles` live pump cycles,
 * host-parked `pumpMs` apart. Any refusal (a degraded socket, a dead tape, a tripped kill-switch)
 * THROWS the typed {@link PaperSessionRefused} — a paper session never limps on.
 */
export async function runPaperSession(opts: RunPaperOptions): Promise<PaperSessionResult> {
  const hooks: PaperHooks = { ...DEFAULT_HOOKS, ...opts.hooks };
  const pumpMs = opts.pumpMs ?? DEFAULT_PUMP_MS;
  const session = await openPaperSession(opts);
  let cycles = 0;
  try {
    for (let i = 0; i < opts.cycles; i++) {
      await hooks.sleep(pumpMs);
      session.pump();
      cycles += 1;
    }
    // A closing reconciliation against the BROKER's authoritative pull: any break trips the switch
    // (fail-closed), so a session never ends claiming a position the venue does not report.
    session.broker.reconcile();
    if (session.broker.killSwitch.tripped) {
      throw new PaperSessionRefused(
        "killed",
        `the closing reconciliation TRIPPED the kill-switch: ${session.broker.killSwitch.reason ?? "(no reason)"}`,
      );
    }
    return {
      events: session.bus.events,
      ledger: session.broker.ledger(),
      positions: session.broker.positions(),
      watermark: session.watermark(),
      cycles,
    };
  } finally {
    session.close();
  }
}
