kestrel.markets
Version:
A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.
666 lines (665 loc) • 42.5 kB
JavaScript
/**
* # adapters/broker — the venue-agnostic broker/feed adapter SEAM (kestrel-7o2.4, increment 1)
*
* The charter claim this module proves: **`sim | paper | live` is ONE Session path where only the
* gate behind the seam differs** (CONTEXT: Mode — "one engine path; only the gate differs"; sim.ts
* §"One code path, one gate"). This file names the two seams that make that true and the mode-keyed
* factory that selects a gate — WITHOUT any IBKR code, any network, any npm dependency, or any
* real-money path (that is a later increment).
*
* ## The execution seam is ALREADY the Gate (engine/plans.ts)
* The engine hands the gate an {@link OrderIntent} that is **fully resolved before it arrives**: a
* numeric `px` (a silent mid is forbidden, RUNTIME §4), never-naked enforced, the price-resolution
* receipt (`sourceAnnotation`) attached, the directional-guard evidence (`moneyness`/`covered`)
* threaded. So a broker adapter is a **TRANSMITTER, never a re-pricer** — it routes the intent to a
* venue and reports back what the venue did. A {@link BrokerAdapter} IS a {@link Gate}.
*
* ## How the reference gate (SimGate, session/sim.ts) surfaces ORDER events — the pattern to MIRROR
* `SimGate.submit` is SYNCHRONOUS: it rests the intent in the {@link SimFillEngine} (the judge) and
* returns a `string` ref. The fill engine APPENDS its typed ORDER events (`place | fill | reject |
* cancel`) to a cumulative `events` buffer; an injected `drain()` closure slices the fresh ones onto
* the emitted bus (stamping `seq`). The engine learns of fills ONLY by OBSERVING `ORDER fill` bus
* events — `SessionCore.step` feeds each drained fill back via `engine.onEvent(fillEvent)` (the
* fills-before-sweep ordering, RUNTIME §7). The engine never simulates a fill itself.
*
* A {@link BrokerAdapter} mirrors this EXACTLY (it does not invent a divergent event path): it holds
* an `events` buffer of seq-less {@link NewBusEvent}s, its `submit`/`cancel` append `place | fill |
* reject | cancel` records to it, and the SAME injected `drain()` surfaces them + feeds fills back to
* the engine. The {@link OrderAction} set is CLOSED (`place | cancel | fill | reject`, bus/types.ts,
* guarded by `isValidStreamType`) — an adapter stays within it.
*
* ## The FEED seam is the bus itself
* There is NO abstract feed interface in the runtime today: `SessionCore.step(ev: BusEvent)` consumes
* the {@link BusEvent} union directly (a `META` header + `TICK/BOOK` carrying the real
* `OptionQuote[]` legs + `underlier_px` + `TICK/SPOT` carrying `px`/`bid`/`ask`). A {@link FeedSource}
* is therefore exactly a **producer of that SAME union** — the mock replays a deterministic in-memory
* sequence; a live feed would translate a venue's market-data stream into the identical shapes. No new
* event type is introduced, so `SessionCore.step` folds a FeedSource's output with zero changes.
*
* ## Mode is first-class, and LIVE fails closed
* `Mode` is `sim | paper | live` (bus/types.ts); the record layer already branches on it (`fidelityOf`,
* `instanceIdentityOf`). The protocol authorization scopes DELIBERATELY EXCLUDE `broker`/`live` from
* {@link WALLET_SIGNABLE_SCOPES} (protocol/index.ts) — **live authority requires a human signature, never
* a wallet/config alone**. {@link makeGate} encodes exactly that boundary: `sim` returns a SimGate
* (byte-identical to today), `paper` returns a {@link BrokerAdapter}-backed gate, and `live` FAILS CLOSED
* with a typed {@link LiveGateRefused} UNLESS an explicit, human-authorized {@link LiveArm} is present.
* **This increment provides NEITHER a live arm NOR live routing** — so the only correct outcome for `live`
* here is the typed refusal.
*
* @remarks Increment 1 is the SEAM + the fail-closed factory. The green implementation (the reference
* paper adapter's fill model, the SimGate construction, the live arm's signature verification) lands
* behind these shapes; the stubs below throw {@link NotImplemented} until then.
*/
// The reference `sim` gate. It IS the venue face for `sim` mode (a Gate), so it lives behind the same
// factory as the paper adapter — `makeGate("sim", …)` returns THIS exact class, byte-identical to the
// hardwired `new SimGate(...)` SessionCore used before (a pure refactor). Value import (constructed
// below); the sim.ts↔adapters edge is a runtime-only cycle (each side names the other solely inside a
// function body / a constructor, never at module-init), so ESM resolves it with live bindings.
import { SimGate } from "../session/sim.js";
/** Build one seq-less ORDER event from a resolved intent, mirroring {@link SimFillEngine}'s private
* `#emit` field-for-field (`order_id`, optional `plan`/`plan_instance`, `instrument`, `side`, `qty`,
* `strike`/`right` when present, then `px` + optional `reason`). The adapter NEVER invents a divergent
* event path — every event inhabits the CLOSED {@link OrderAction} vocabulary and reads back through
* the same projector the sim ORDER events do. */
function orderEvent(action, ts, intent, extra) {
return {
ts,
stream: "ORDER",
type: action,
order_id: intent.ref,
...(intent.plan !== undefined ? { plan: intent.plan } : {}),
...(intent.plan_instance !== undefined ? { plan_instance: intent.plan_instance } : {}),
instrument: intent.instrument,
side: intent.side,
qty: intent.qty,
...(intent.strike !== undefined ? { strike: intent.strike } : {}),
...(intent.right !== undefined ? { right: intent.right } : {}),
px: extra.px,
...(extra.reason !== undefined ? { reason: extra.reason } : {}),
};
}
/**
* The reference **mock/paper** broker adapter (kestrel-7o2.4): a deterministic, in-memory,
* ZERO-NETWORK venue that transmits an intent and reports a deterministic paper fill back as an
* `ORDER fill` event — the full paper loop with no IBKR code, no wall clock, no RNG.
*
* It mirrors {@link SimFillEngine}'s ORDER-event flow EXACTLY: `submit` appends a `place` then a `fill`
* (both at the intent's ALREADY-RESOLVED `px` — a TRANSMITTER, never a re-pricer) to its cumulative
* {@link BrokerAdapter.events} buffer, then calls the injected {@link PaperBrokerDeps.drain} — the SAME
* closure {@link SimGate} calls — which surfaces the fresh events onto the emitted bus and feeds the
* `fill` back to the engine via `engine.onEvent`. Because the paper venue fills on submit, no order ever
* rests, so `cancel` is a fail-closed no-op (never a crash).
*/
class PaperBroker {
now = 0;
#events = [];
#drain;
constructor(deps) {
// `multiplier` is carried for parity with the fill engine; the paper venue's deterministic fill
// model uses the intent's own `px` as the transmitted limit, so it never enters the price path.
void deps.multiplier;
this.#drain = deps.drain;
}
/** The cumulative typed ORDER events this adapter has produced, in order (read-only). A `fill` here
* is what the engine OBSERVES through `engine.onEvent`; the injected `drain` stamps `seq`. */
get events() {
return this.#events;
}
submit(intent) {
// TRANSMITTER (never-naked upstream, RUNTIME §4): transmit the resolved intent as a `place`, then
// report the deterministic paper fill — both at `intent.px`, never a re-priced/mid anchor. Append
// to the buffer FIRST, then drain, exactly as SimGate rests-then-drains.
this.#events.push(orderEvent("place", this.now, intent, { px: intent.px }));
this.#events.push(orderEvent("fill", this.now, intent, { px: intent.px, reason: "paper" }));
this.#drain();
return intent.ref; // the ref the engine correlates the fill against (mirrors SimGate echoing the ref)
}
cancel(_ref) {
// The paper venue fills on submit, so nothing rests — a cancel is a fail-closed no-op. Still drains
// (zero fresh events ⇒ harmless) to keep the rest-then-drain shape identical to SimGate.cancel.
this.#drain();
}
/** The venue's AUTHORITATIVE position PULL (kestrel-7o2.9): net-of-fills, signed (buy `+`, sell `−`)
* per {@link PositionKey}, folded from this adapter's own `ORDER fill` records. Deterministic (no
* clock/RNG) and read-only — the reconciliation-trip anchors to THIS, the broker's own truth, not the
* engine's push stream (ADR-0034 §4). A push-only live transport that cannot pull would simply omit
* this method; the paper double is fully queryable. */
positions() {
const snap = {};
for (const ev of this.#events) {
if (ev.type !== "fill")
continue;
const key = positionKeyOf(ev);
snap[key] = (snap[key] ?? 0) + (ev.side === "buy" ? ev.qty : -ev.qty);
}
return snap;
}
}
/**
* The reference **mock/paper** broker adapter (kestrel-7o2.4): a deterministic, in-memory,
* ZERO-NETWORK venue that transmits an intent and reports a deterministic paper fill back as an
* `ORDER fill` event — the full paper loop with no IBKR code.
*/
export function makePaperBroker(deps) {
return new PaperBroker(deps);
}
/** The reference **mock** feed (kestrel-7o2.4): replays a fixed in-memory {@link BusEvent} sequence —
* the SAME union `SessionCore.step` already folds, so it consumes the feed with zero changes and no new
* event type. Deterministic (no clock/RNG): re-iterable, byte-stable across calls. A live feed is a
* drop-in that yields the identical union off a venue's market-data stream. */
class ReplayFeed {
#events;
constructor(events) {
this.#events = events;
}
events() {
return this.#events;
}
}
export function replayFeed(events) {
return new ReplayFeed(events);
}
/** The UNBOUNDED paper-clamp limits (kestrel-7o2.21) — used when `makeGate("paper", …)` is called with
* NO {@link PaperGateDeps.limits} config. Every ceiling is `+Infinity`, so no order is ever refused for
* size/position/notional, yet the paper gate is STILL a composed {@link LiveClamp} (the kill-switch and
* reconciliation-trip remain reachable) rather than the bare adapter. This keeps the pre-clamp paper
* path's emitted bytes unchanged while making the L0 envelope a real, reachable call site. */
export const UNBOUNDED_PAPER_LIMITS = {
maxOrderQty: Number.POSITIVE_INFINITY,
maxPositionQty: Number.POSITIVE_INFINITY,
maxNotionalUsd: Number.POSITIVE_INFINITY,
};
/** The unforgeable {@link LiveArm} token (kestrel-7o2.9). NOT exported — the private `#brand` field
* blocks structural forgery AND the class value is unreachable outside this module, so {@link armLive}
* (signature-verified) is the sole mint. */
class LiveArmToken {
limits;
grant;
/** Nominal, private brand — no object literal can carry it, so `#brand in x` is a forgery-proof
* runtime check and the type is not structurally assignable from a plain object. */
#brand = true;
constructor(
/** The risk limits the human signature authorized. */
limits,
/** The verified grant this arm was minted from (its `signatureRef` is the authority receipt). */
grant) {
this.limits = limits;
this.grant = grant;
void this.#brand;
}
/** The forgery-proof runtime brand check — true iff `x` was minted by {@link armLive}. */
static has(x) {
return typeof x === "object" && x !== null && #brand in x;
}
}
const PAPER_VENUES = new Map();
/**
* Register a venue adapter as the {@link BrokerAdapter} `makeGate("paper", { venue })` serves
* (kestrel-7o2.8). The venue's own module calls this at its composition root, so THIS module never
* imports a venue transport — `sim` keeps its zero-dependency path, byte-identically. Registration is
* PAPER-ONLY by construction: nothing here can unlock `live`, which still requires the human-signed
* {@link LiveArm} and refuses with {@link LiveGateRefused} regardless of what is registered.
*/
export function registerPaperVenue(venue, make) {
PAPER_VENUES.set(venue, make);
}
/** The paper venues registered so far, sorted (a stable, loggable list — never an RNG/insertion-order
* leak into a diagnostic). */
export function registeredPaperVenues() {
return [...PAPER_VENUES.keys()].sort();
}
/** Resolve a registered paper venue, or refuse. An UNREGISTERED name is a LOUD error, never a silent
* fallthrough to some other gate (fail-closed). Absent name ⇒ `undefined` (the caller then requires
* an explicit {@link GateDeps.broker}). */
function resolvePaperVenue(venue) {
if (venue === undefined)
return undefined;
const make = PAPER_VENUES.get(venue);
if (make === undefined) {
const known = registeredPaperVenues();
throw new Error(`makeGate: mode 'paper' names the UNREGISTERED venue ${JSON.stringify(venue)} (registered: ${known.length === 0 ? "none" : known.join(", ")}) — fail-closed, never a silent fallthrough`);
}
return make();
}
// ── ONE shared kill-switch + TWO independently-proven ceilings (kestrel-7o2.8, defense-in-depth) ────
//
// A venue adapter MAY already carry its own {@link KillSwitch}, {@link RiskLimits}, and per-contract
// multiplier (the IBKR paper face does — its inbound pump trips a switch the moment the venue's own
// report proves a never-naked break, and it resolves each contract's true multiplier). The design here
// is deliberately defense-in-depth, and the two axes are NOT symmetric:
//
// • KILL-SWITCH — exactly ONE, SHARED. `makeGate("paper", …)` reads the venue's own switch
// (structurally — the seam never imports a venue module) and threads it INTO the clamp, so a trip
// in EITHER layer (an operator STAND_DOWN on the seam, or the adapter's at-observation never-naked
// trip) halts the WHOLE path. A second independent switch would leave a trip unable to reach the
// other layer — the hole this closes.
// • RISK CEILINGS — TWO layers, each INDEPENDENTLY LOAD-BEARING (ADR-0034 §4 defense-in-depth): the
// SEAM clamp (this makeLiveClamp, L0) and the adapter's own venue-boundary WALL 5. The seam reads
// the venue's SAME numeric limits + true per-contract multiplier and threads them in, so the two
// never drift to divergent numbers — but neither is a mere backstop to a single "canonical owner":
// a bare adapter (no seam) must still refuse, and a bare seam over a WALL-5-less adapter must still
// refuse. Each is proven load-bearing by a mutation test that guts it and watches a test go RED.
/** Structural {@link KillSwitch} probe — true iff `x` presents the switch's read-and-trip surface. */
function isKillSwitch(x) {
return (typeof x === "object" &&
x !== null &&
typeof x.tripped === "boolean" &&
typeof x.trip === "function");
}
/** Structural {@link RiskLimits} probe — true iff `x` carries all three numeric L0 ceilings. */
function isRiskLimits(x) {
return (typeof x === "object" &&
x !== null &&
typeof x.maxOrderQty === "number" &&
typeof x.maxPositionQty === "number" &&
typeof x.maxNotionalUsd === "number");
}
/** The venue adapter's OWN kill-switch, if it self-guards (e.g. the IBKR paper face) — else `undefined`
* (the reference mock/paper broker has none, so the clamp mints a fresh one, unchanged). Read
* structurally so the seam never imports a venue transport. */
function venueKillSwitchOf(broker) {
const k = broker.killSwitch;
return isKillSwitch(k) ? k : undefined;
}
/** The venue adapter's OWN configured L0 ceilings, if it exposes them — so the seam clamp enforces the
* SAME limits (canonical, above the adapter) instead of an UNBOUNDED no-op. `undefined` for a venue
* that carries none. Read structurally so the seam never imports a venue transport. */
function venueLimitsOf(broker) {
const l = broker.limits;
return isRiskLimits(l) ? l : undefined;
}
/** The venue adapter's OWN per-intent contract multiplier resolver, if it exposes one (kestrel-7o2.8) —
* the IBKR paper face resolves it from its ContractBook (an option `100`, an equity `1`), so the seam
* clamp computes `notional = px·qty·multiplier` on the venue's TRUE per-contract multiplier rather than
* a flat `1×` that is 100× too loose for a 100× option. `undefined` for a venue that carries none (the
* reference mock/paper broker), so the clamp keeps its configured flat multiplier. Read structurally so
* the seam never imports a venue transport; the returned closure re-binds to the broker on each call. */
function venueMultiplierOf(broker) {
const m = broker.multiplierOf;
if (typeof m !== "function")
return undefined;
return (intent) => broker.multiplierOf(intent);
}
/**
* The typed, fail-closed refusal {@link makeGate} raises for a `live` gate requested without an explicit
* {@link LiveArm} (kestrel-7o2.4). A DISTINCT error class (not a bare `Error`) so a caller can catch the
* live-authority refusal specifically and never mistake it for an unrelated failure — and so a `live`
* request can NEVER silently fall through to a routing gate. Mirrors the protocol boundary: `live` is not
* in {@link WALLET_SIGNABLE_SCOPES}; live authority requires a human signature (RUNTIME §8, fail-closed).
*/
export class LiveGateRefused extends Error {
mode = "live";
constructor(reason) {
super(reason);
this.name = "LiveGateRefused";
}
}
/** Thrown by the reference stubs until the green implementation lands (kestrel-7o2.4 increment 1 is the
* SEAM + the fail-closed factory; the behavior behind the shapes is a later increment). Distinct from
* {@link LiveGateRefused} so a RED test can tell "not yet built" from "live refused by design". */
export class NotImplemented extends Error {
constructor(what) {
super(`kestrel-7o2.4: ${what} is not implemented in this increment (SEAM + RED phase)`);
this.name = "NotImplemented";
}
}
export function makeGate(mode, deps) {
switch (mode) {
case "sim": {
// BYTE-IDENTICAL to the hardwired `new SimGate(this.fill, this.spotFill, …, () => void this.#drain())`
// — the same class over the same ingredients, so the sim path's emitted bytes never move.
const sim = deps.sim;
if (sim === undefined) {
throw new Error("makeGate: mode 'sim' requires deps.sim (the SimFillEngine ingredients) — none present (fail-closed)");
}
return new SimGate(sim.fill, sim.spotFill, sim.spotAllowed, sim.drain);
}
case "paper": {
// The broker is resolved EITHER handed in directly (`deps.broker`, the reference mock/paper
// double) OR through the venue REGISTRY (`deps.venue`, kestrel-7o2.8 — e.g. the IBKR paper face,
// which registers itself so this module never imports its socket transport). An unregistered
// venue is refused inside `resolvePaperVenue`; neither door can reach `live`.
// The resolved broker is then CLAMPED (kestrel-7o2.21): the L0 pre-transmit clamp wraps the paper
// broker so an over-limit / killed order is REFUSED before it reaches the venue. NEVER the bare
// adapter. Authority is a PaperArm (config limits, not a human signature): paper carries no
// real-money risk and is not human-signable, yet the clamp still records `provenance: "paper"` so
// this gate can never be reached as a live gate.
const broker = deps.broker ?? resolvePaperVenue(deps.venue);
if (broker === undefined) {
throw new Error(`makeGate: mode 'paper' requires deps.broker (a BrokerAdapter) or a registered deps.venue (registered: ${registeredPaperVenues().join(", ") || "none"}) — none present (fail-closed)`);
}
const cfg = deps.paper;
// SINGLE KILL-SWITCH (kestrel-7o2.8). If the resolved venue adapter self-guards (the IBKR paper
// face trips a switch from INSIDE its inbound pump on an observed never-naked break), that ONE
// switch must ALSO be the switch this seam clamp consults — otherwise the clamp mints a second,
// independent one and a trip in either layer can never reach the other (the double-kill-switch
// hole). Thread the venue's own switch into makeLiveClamp so a trip in EITHER layer halts the
// WHOLE path. An explicit `cfg.killSwitch` still wins (a caller wiring one shared switch by hand).
const killSwitch = cfg?.killSwitch ?? venueKillSwitchOf(broker);
// TWO INDEPENDENTLY-PROVEN L0 CEILINGS, DEFENSE-IN-DEPTH (ADR-0034 §4). This is NOT one canonical
// owner with a subordinate backstop — it is a SEAM ceiling (this makeLiveClamp, L0) AND an adapter
// venue-boundary ceiling (the IBKR face's WALL 5), each proven load-bearing on its own (a bare
// adapter with no seam still refuses; a bare seam over an adapter with no WALL 5 still refuses).
// Prefer the venue adapter's OWN configured ceilings so the SEAM clamp enforces the SAME numeric
// limits — read from the venue so the two layers can never drift to divergent numbers. Absent both
// ⇒ UNBOUNDED (the reference mock/paper broker), so that path's bytes stay unchanged. This retires
// the "benign UNBOUNDED no-op that masks the seam": the seam ceiling is now real, not a pass-through.
const limits = cfg?.limits ?? venueLimitsOf(broker) ?? UNBOUNDED_PAPER_LIMITS;
const arm = cfg?.arm ?? makePaperArm(limits);
const brokerPull = () => broker.positions?.() ?? {};
// TRUE PER-CONTRACT MULTIPLIER (kestrel-7o2.8, the notional-100x fix). The seam clamp bounds
// `notional = px·qty·multiplier`; on the registry path `deps.paper` is undefined, so before this
// fix the multiplier defaulted to `1` and the seam's maxNotionalUsd ceiling was 100× too loose for
// a 100× option — the adapter's WALL 5 was then the ONLY correct notional bound. Now the seam READS
// the venue's own per-intent multiplier (option 100, equity 1) and threads it in, so the seam L0
// ceiling is computed on REAL notional. An explicit `cfg.multiplier` still wins; a venue that
// exposes none (the reference mock/paper broker) falls back to `1`, unchanged.
return makeLiveClamp({
underlying: broker,
arm,
multiplier: cfg?.multiplier ?? venueMultiplierOf(broker) ?? 1,
positions: cfg?.positions ?? brokerPull,
brokerPositions: cfg?.brokerPositions ?? brokerPull,
...(killSwitch !== undefined ? { killSwitch } : {}),
...(cfg?.tolerance !== undefined ? { tolerance: cfg.tolerance } : {}),
});
}
case "live": {
// FAIL CLOSED. Live authority is NOT wallet-signable (the protocol excludes `broker`/`live` from
// WALLET_SIGNABLE_SCOPES) — a human signature is required. Without an explicit arm the ONLY correct
// outcome is the typed refusal; the `live` request can NEVER silently fall through to a routing gate.
const arm = deps.liveArm;
if (arm === undefined) {
throw new LiveGateRefused("live authority requires an explicit, human-signed Kestrel arm — none present (fail-closed; live is not wallet-signable, protocol/index.ts, RUNTIME §8)");
}
// MODE WALL (kestrel-7o2.21): the arm must be a signature-verified LiveArm. A PaperArm (config
// authority) or any forgery is NOT a LiveArm, so it can NEVER unlock live — refuse fail-closed. This
// is what makes a PaperArm paper-only: it authorizes the paper clamp, never a live gate.
if (!isLiveArm(arm)) {
throw new LiveGateRefused("live authority requires a human-signed LiveArm minted by armLive — the supplied arm was not (a PaperArm or forgery can never authorize live, fail-closed)");
}
// A genuine LiveArm was supplied, but THIS increment ships no live routing (the seam is declared,
// not wired). Honestly distinct from the by-design refusal above, and still never a live gate.
throw new NotImplemented("live gate routing");
}
default: {
// Exhaustive over the closed Mode vocabulary — an unknown mode is refused, never a silent default.
const never = mode;
throw new Error(`makeGate: unknown mode ${JSON.stringify(never)} (fail-closed)`);
}
}
}
/** The {@link PositionKey} for an order/leg (kestrel-7o2.9) — pure, so the clamp, the engine-expected
* snapshot, and the broker PULL all key identically (no skew). */
export function positionKeyOf(o) {
return o.strike !== undefined && o.right !== undefined ? `${o.instrument}|${o.strike}|${o.right}` : o.instrument;
}
/**
* Mint an unforgeable {@link LiveArm} from a human-signed {@link LiveAuthorityGrant} (kestrel-7o2.9) —
* the SOLE construction path. Verifies the grant's signature through the injected {@link AuthoritySigner}
* and, only if it verifies, `new`s the branded token carrying the signed {@link RiskLimits}. FAIL-CLOSED:
* a grant whose signature does not verify (a wallet/config forgery, a widened-after-signing limit set)
* throws {@link LiveGateRefused} — it can NEVER mint an arm. Live is not wallet-signable (protocol
* excludes `broker`/`live` from {@link WALLET_SIGNABLE_SCOPES}); this is the human-signature wall.
*/
export function armLive(grant, signer) {
// Wall 1 — the scope must be the non-wallet-signable `"live"` (protocol excludes it from
// WALLET_SIGNABLE_SCOPES); a grant claiming any other scope can never be a live arm.
if (grant.scope !== "live") {
throw new LiveGateRefused(`armLive: a live arm requires scope "live" (got ${JSON.stringify(grant.scope)}) — fail-closed (live is not wallet-signable, protocol/index.ts)`);
}
// Wall 2 — the human signature over the EXACT limits must verify. A wallet/config forgery, or a limit
// set widened after signing, fails here and can NEVER mint an arm (fail-closed, RUNTIME §8).
if (!signer.verify(grant)) {
throw new LiveGateRefused("armLive: the human signature over the risk limits did not verify — a wallet/config forgery or a widened-after-signing limit set cannot mint a live arm (fail-closed)");
}
// Verified — mint the branded token carrying exactly the limits the human signed over. This is the
// SOLE construction site (the class value is not exported), so `isLiveArm` is a total forgery check.
return new LiveArmToken(grant.limits, grant);
}
/** The forgery-proof runtime brand check (kestrel-7o2.9) — true iff `arm` was minted by {@link armLive}.
* A plain object literal (even one shaped like a grant, even cast `as unknown as LiveArm`) returns
* `false`: the private `#brand` is unreachable except through the sole mint. REAL (not a stub) — a
* brand read has no behavior to defer, and the clamp/factory rely on it to reject fabricated arms. */
export function isLiveArm(arm) {
return LiveArmToken.has(arm);
}
/** The {@link RiskLimits} an {@link LiveArm} authorizes (kestrel-7o2.9) — the exact limits the human
* signed over, read off the opaque token. REAL (not a stub). */
export function armLimits(arm) {
return arm.limits;
}
/** The branded {@link PaperArm} token (kestrel-7o2.21). NOT exported — the private `#paperBrand` field
* (distinct from {@link LiveArmToken}'s `#brand`) blocks structural forgery AND makes it un-confusable
* with a LiveArm, so {@link makePaperArm} is the sole mint and {@link isPaperArm} is a total check. */
class PaperArmToken {
limits;
/** Nominal, private brand — distinct from {@link LiveArmToken.prototype} so no PaperArm is ever an
* accidental LiveArm; `#paperBrand in x` is a forgery-proof runtime check. */
#paperBrand = true;
constructor(
/** The config-supplied risk limits this paper arm authorizes (NOT a human signature). */
limits) {
this.limits = limits;
void this.#paperBrand;
}
/** True iff `x` was minted by {@link makePaperArm}. */
static has(x) {
return typeof x === "object" && x !== null && #paperBrand in x;
}
}
/**
* Mint a {@link PaperArm} from plain config {@link RiskLimits} (kestrel-7o2.21) — the paper-mode
* counterpart to {@link armLive}, but with NO signature verification, because paper carries no
* real-money risk and is deliberately NOT human-signable. The minted arm is a branded token, so a
* forged/literal object still cannot pass as authority (the {@link makeLiveClamp} construction guard
* rejects anything neither {@link isLiveArm} nor {@link isPaperArm}). It authorizes ONLY paper: it is
* not a {@link LiveArm} and can never unlock `makeGate("live", …)`.
*/
export function makePaperArm(limits) {
return new PaperArmToken(limits);
}
/** The forgery-proof runtime brand check for a {@link PaperArm} (kestrel-7o2.21) — true iff `arm` was
* minted by {@link makePaperArm}. A plain literal (even cast `as unknown as PaperArm`) returns `false`,
* and a genuine {@link LiveArm} returns `false` too (distinct brand): paper and live authority never
* cross-authorize. REAL (not a stub). */
export function isPaperArm(arm) {
return PaperArmToken.has(arm);
}
/**
* The typed, fail-closed refusal the {@link makeLiveClamp L0 clamp} raises when an order is over a
* {@link RiskLimits} ceiling OR the {@link KillSwitch} is tripped (kestrel-7o2.9). A DISTINCT error
* class (not a bare `Error`, distinct from {@link LiveGateRefused}) so a caller catches an L0 refusal
* specifically. The order is NEVER transmitted — the clamp throws BEFORE it calls the underlying
* adapter's `submit` (ADR-0034 §4/§7, bounded-risk / never-naked). Carries the tripped {@link ClampLimit}
* and the refused order's `ref` for the logged reason.
*/
export class ClampRefused extends Error {
limit;
ref;
constructor(limit, ref, reason) {
super(reason);
this.name = "ClampRefused";
this.limit = limit;
this.ref = ref;
}
}
/** Build a fresh, un-tripped {@link KillSwitch} (kestrel-7o2.9). Latches ON — `trip` is idempotent
* (first reason wins) and there is NO un-trip on the seam; re-arming is a fresh human act, out of band. */
export function makeKillSwitch() {
let tripped = false;
let reason = null;
return {
get tripped() {
return tripped;
},
get reason() {
return reason;
},
trip(r) {
// Idempotent latch: only the FIRST trip records its reason; subsequent trips are no-ops (the switch
// never un-trips). STAND_DOWN is always reachable; once tripped, every consulting transmit refuses.
if (!tripped) {
tripped = true;
reason = r;
}
},
};
}
/**
* Build the {@link LiveClamp L0 pre-transmit risk clamp} over an underlying {@link BrokerAdapter}
* (kestrel-7o2.9). FAIL-CLOSED at construction: a forged/literal {@link LiveArm} (one {@link isLiveArm}
* rejects) throws {@link LiveGateRefused} — the envelope can never be armed on fabricated authority.
* The returned clamp IS a BrokerAdapter (a Gate), so it drops into the same execution seam; its `submit`
* enforces the arm's {@link RiskLimits} + the {@link KillSwitch} above the underlying adapter, and
* {@link LiveClamp.reconcile} trips the switch on a broker-vs-engine position break.
*/
export function makeLiveClamp(deps) {
return new LiveClampImpl(deps);
}
/**
* The concrete {@link LiveClamp} — the L0 pre-transmit risk clamp (kestrel-7o2.9). It IS a
* {@link BrokerAdapter} (a {@link Gate}), so it drops into the ONE execution seam and the underlying
* adapter cannot be reached without passing through it. Every guard runs BEFORE the delegated
* `underlying.submit`, so an over-limit / killed order is NEVER transmitted (the underlying's `.events`
* stays untouched) — fail-closed, bounded-risk above the adapter (ADR-0034 §4/§7). It is a TRANSMITTER,
* never a re-pricer: it either forwards `intent` byte-for-byte or REFUSES; it never touches `intent.px`.
*/
class LiveClampImpl {
#underlying;
#limits;
provenance;
#positions;
#brokerPositions;
#multiplier;
#tolerance;
killSwitch;
constructor(deps) {
// FAIL-CLOSED at construction: the authority must be a BRANDED token — a signature-verified LiveArm
// (minted by `armLive`) or a config-minted PaperArm (minted by `makePaperArm`). A forged/literal arm
// is NEITHER (no private brand), so it is rejected here — the envelope can never be armed on
// fabricated authority. The clamp RECORDS which authority built it: a PaperArm makes it paper-only
// (it can never be reached as a live gate; `makeGate("live")` refuses a PaperArm at the factory).
if (isLiveArm(deps.arm)) {
this.provenance = "live";
}
else if (isPaperArm(deps.arm)) {
this.provenance = "paper";
}
else {
throw new LiveGateRefused("makeLiveClamp: the supplied arm was minted by neither armLive (a verified human signature ⇒ LiveArm) nor makePaperArm (config ⇒ PaperArm) — the L0 clamp can never be built on fabricated authority (fail-closed)");
}
this.#underlying = deps.underlying;
this.#limits = deps.arm.limits;
this.#positions = deps.positions;
this.#brokerPositions = deps.brokerPositions;
this.#multiplier = deps.multiplier;
this.#tolerance = deps.tolerance ?? 0;
this.killSwitch = deps.killSwitch ?? makeKillSwitch();
}
/** The driver pins the clock on the clamp; it flows straight through to the underlying transmitter so
* the emitted ORDER events carry the same `now` (the gate seam is `now`-less, RUNTIME §0). */
get now() {
return this.#underlying.now;
}
set now(v) {
this.#underlying.now = v;
}
/** The underlying transmitter's ORDER events — the clamp adds no event path of its own (it either
* forwards to `underlying.submit` or refuses before any event is produced). */
get events() {
return this.#underlying.events;
}
submit(intent) {
// (0) Kill-switch FIRST — once tripped (operator STAND_DOWN or a reconciliation break) nothing
// transmits. Consulted before the limits so a halted process refuses uniformly.
if (this.killSwitch.tripped) {
throw new ClampRefused("killed", intent.ref, `live transmission halted — kill-switch tripped: ${this.killSwitch.reason ?? "(no reason)"} (fail-closed)`);
}
const limits = this.#limits;
// (i) WELL-FORMEDNESS FIRST — a ceiling comparison on a corrupt number FAILS OPEN (kestrel-7o2.10,
// A3). `NaN > maxOrderQty`, `NaN > maxPositionQty` and `NaN > maxNotionalUsd` are ALL `false`, so a
// NaN qty/px satisfies every ceiling below and TRANSMITS — the exact inversion of fail-closed. The
// check must precede the ceilings, not sit beside them. This is the SEAM's own wall: the venue face's
// WALL 1 refuses a malformed intent too, but each L0 layer is load-bearing ALONE (ADR-0034 §4) — the
// clamp is the only ceiling over a WALL-less/bare adapter and may not delegate its own validity.
if (!Number.isFinite(intent.qty)) {
throw new ClampRefused("not-a-number", intent.ref, `order qty ${intent.qty} is not a finite number — every ceiling comparison against it is false, so it would satisfy maxOrderQty/maxPositionQty/maxNotionalUsd and transmit (fail-closed, bounded-risk)`);
}
if (!Number.isFinite(intent.px)) {
throw new ClampRefused("not-a-number", intent.ref, `order px ${intent.px} is not a finite number — notional = px·qty·multiplier would be NaN and clear maxNotionalUsd silently (fail-closed, bounded-risk)`);
}
// (i-b) ORDERABILITY (kestrel-ct9m) — finite is NOT enough. A FINITE but unorderable qty/px fails
// every ceiling open in exactly the same direction as the NaN above:
// `-1_000_000 > maxOrderQty` ⇒ false — the size ceiling passes
// `px · -1_000_000 · mult > maxNotionalUsd` ⇒ false — the notional is NEGATIVE, the ceiling passes
// `projected = current + (buy ? qty : -qty)` ⇒ SIGN-INVERTED — a sell ADDS to the position
// so a million-lot sell cleared all three L0 walls and transmitted. A `0`/negative px collapses or
// inverts the notional the same way, and a fractional qty is not an orderable lot at any venue.
// The seam refuses on its OWN account: IBKR's venue face (WALL 1) rejects these too, but ADR-0034 §4
// makes each L0 layer load-bearing ALONE — a bare seam over a transport with no WALL 1 (the reference
// mock today, a future live transport tomorrow) is then the only wall, and it may not delegate its
// own validity. Fixture: tests/adapters.clamp-malformed-intent.test.ts (bare transport, both drivers).
if (!Number.isInteger(intent.qty) || intent.qty <= 0) {
throw new ClampRefused("malformed-intent", intent.ref, `order qty ${intent.qty} is not a positive integer — a negative/zero/fractional qty satisfies maxOrderQty and maxNotionalUsd by comparison (both are \`>\` tests a non-positive value passes) and sign-inverts the projected position, so it would transmit unbounded (fail-closed, bounded-risk)`);
}
if (intent.px <= 0) {
throw new ClampRefused("malformed-intent", intent.ref, `order px ${intent.px} is not a positive number — notional = px·qty·multiplier would be zero or negative and the maxNotionalUsd ceiling ${limits.maxNotionalUsd} would enforce nothing (fail-closed, bounded-risk)`);
}
// (ii) Max single-order size.
if (intent.qty > limits.maxOrderQty) {
throw new ClampRefused("order-size", intent.ref, `order qty ${intent.qty} exceeds maxOrderQty ${limits.maxOrderQty} (fail-closed, bounded-risk)`);
}
// (iii) Max ABSOLUTE net position per key, given the engine-EXPECTED current positions.
const key = positionKeyOf(intent);
const current = this.#positions()[key] ?? 0;
const projected = current + (intent.side === "buy" ? intent.qty : -intent.qty);
if (Math.abs(projected) > limits.maxPositionQty) {
throw new ClampRefused("position", intent.ref, `projected net position ${projected} at ${key} exceeds maxPositionQty ${limits.maxPositionQty} (fail-closed)`);
}
// (iv) THE MULTIPLIER ITSELF, before it is trusted to compute a ceiling (kestrel-7o2.10, A3). The
// notional ceiling is only as real as its inputs: a `0` multiplier computes EVERY notional as `0`, so
// `0 > maxNotionalUsd` is false and the ceiling silently enforces NOTHING; a NaN/negative/absent one
// corrupts it the same way. This is REACHABLE, not theoretical — `makeGate("paper")` threads the
// VENUE's own `multiplierOf`, and the IBKR face returns `contract.multiplier ?? CONSERVATIVE`, where
// `??` does NOT catch a `0` a malformed contractDetails resolved. A multiplier that cannot bound a
// notional is refused rather than believed.
const multiplier = typeof this.#multiplier === "function" ? this.#multiplier(intent) : this.#multiplier;
if (!Number.isFinite(multiplier) || multiplier <= 0) {
throw new ClampRefused("multiplier", intent.ref, `contract multiplier ${multiplier} is not a finite positive number — notional = px·qty·multiplier would be meaningless and the maxNotionalUsd ceiling ${limits.maxNotionalUsd} would enforce nothing (fail-closed, bounded-risk)`);
}
// (v) Max notional per order = px · qty · multiplier (never re-priced — `intent.px` is the resolved
// limit). The multiplier is the venue's TRUE per-contract one when a resolver was threaded in
// (kestrel-7o2.8: option 100, equity 1), so a 100× option's notional is not computed 100× too loose.
const notional = intent.px * intent.qty * multiplier;
if (notional > limits.maxNotionalUsd) {
throw new ClampRefused("notional", intent.ref, `order notional ${notional} exceeds maxNotionalUsd ${limits.maxNotionalUsd} (fail-closed)`);
}
// Every ceiling clears and the switch is live — transmit the resolved intent UNCHANGED (transmitter).
return this.#underlying.submit(intent);
}
/** Cancels are risk-REDUCING — always forwarded, even while halted (pulling resting orders is part of
* STAND_DOWN). A fail-closed no-op downstream on an unknown/terminal ref. */
cancel(ref) {
this.#underlying.cancel(ref);
}
/** The clamp's authoritative position PULL is the broker's own — the reconciliation anchor it was
* wired with (ADR-0034 §4). Read-only; never re-prices. */
positions() {
return this.#brokerPositions();
}
reconcile() {
const expected = this.#positions();
const actual = this.#brokerPositions();
// Compare per key across BOTH snapshots' union: a broker fill the engine never originated (expected
// 0 / actual N) AND an engine order with no broker terminal state (expected N / actual 0) both break.
for (const key of new Set([...Object.keys(expected), ...Object.keys(actual)])) {
const e = expected[key] ?? 0;
const a = actual[key] ?? 0;
if (Math.abs(a - e) > this.#tolerance) {
this.killSwitch.trip(`reconciliation break at ${key}: engine expected ${e}, broker PULL reported ${a} (Δ ${a - e}, tolerance ${this.#tolerance}) — halting live transmission (ADR-0034 §4, fail-closed)`);
return; // first break latches the switch; every subsequent submit refuses "killed"
}
}
}
}