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.
755 lines (754 loc) • 201 kB
JavaScript
/**
* # engine/plans — the plan lifecycle runtime (RUNTIME §5)
*
* The heart of the system: a **pure, injected-time** {@link PlanEngine} that arms parsed
* Kestrel documents, sweeps their triggers over a bus event stream, and drives every plan
* through its lifecycle `authored → armed → fired → managing → done(filled|expired|invalidated)`
* (RUNTIME §5) with **fire-then-inform** semantics — on a definite trigger it submits children
* through the Gate *and* emits a `WAKE` in the same step (ARCHITECTURE §1: slow judgment
* compiled into a fast reflex).
*
* It sits **above** the shipped substrate and owns none of it:
* - price resolution is delegated to {@link resolvePrice} (`./pricing.ts`, RUNTIME §4) — the one
* place that knows `fair`/`mid`/`peg`/`esc`/`cap`/`floor`;
* - triggers are evaluated by the injected {@link TriggerEvaluator} factory over a
* {@link SeriesProvider} (`src/series`, tri-state + causal, RUNTIME §3) — one evaluator per
* trigger per plan instance so each carries its own edge memory;
* - orders go out through the injected {@link Gate} (`submit`/`cancel`) and fills come *back* as
* `ORDER fill` bus events observed in {@link PlanEngine.onEvent} — the engine never simulates a
* fill itself (the fill engine behind the gate is the judge, RUNTIME §6);
* - `pnl` / `fills.*` / `plan(x).*` org facts are served from the engine's {@link BookLedger}
* through {@link EngineOrgFacts} (`./orgfacts.ts`).
*
* All time is injected (RUNTIME §0): every method takes `now` from the event; the engine holds
* no clock and no RNG. **Same bus + same armed documents ⇒ byte-identical emitted stream** — the
* determinism invariant, certified by comparing {@link PlanEngine.dumpState} across two replays.
*
* ## Sweep order (one pass per event — never a fixpoint)
* Each trigger is evaluated **exactly once per event** so its causal edge memory (crosses,
* held, within, nth) advances correctly — re-evaluating a `crosses` twice in one tick would
* consume the transition. Consequence: plan **chaining** (`plan(a).fired` gating plan `b`)
* resolves on the **next** event, not the same one (the fired latch is on the ledger, read on
* the following sweep — RUNTIME §5 "plan-state triggers read the bus lifecycle").
*
* The per-event pass:
* 1. **ingest** — fold TICK into spot/book, REGIME into the tag store, correlate `ORDER`
* fill/cancel/reject into inventory + the ledger.
* 2. **reconcile arming** — an armed plan whose regime gate goes UNKNOWN de-arms; an authored
* plan whose gate is (re)satisfied arms; a past-TTL armed plan expires (RUNTIME §3/§5).
* 3. **fire** — collect armed plans whose `WHEN` is definitely true, admit by envelope +
* priority arbitration (higher wins, ties → earlier armed), fire the admitted (submit +
* inform).
* 4. **manage** — for fired plans: line/plan `CANCEL-IF`, `INVALIDATE`, `EXIT` (+ esc/STAND),
* `RELOAD` rungs, `TP` maintenance, `TTL`, and peg/esc reprice of working orders.
*/
import { foldBook } from "../bus/index.js";
import { intrinsic, buildSurface, impliedForward, interpIv } from "../fair/index.js";
import { greeks } from "../fair/black76.js";
import { guardIntent } from "../fill/index.js";
import { durationMs, isUnknown, UNKNOWN, TriggerEvaluator, defaultSeriesRegistry, } from "../series/index.js";
import { assertNever } from "../lang/index.js";
import { unknownSeriesRejection } from "../validate/index.js";
import { isUnresolvable, resolvePrice } from "./pricing.js";
import { BookLedger, EngineOrgFacts } from "./orgfacts.js";
import { perLegFillQualifierReason } from "./disarm.js";
import { sha256 } from "../crypto/sha256.js";
/**
* A whole-document arm refusal that MUST halt arming (OSS-ADR-0045). It carries a
* wire-stable `.code` from {@link ArmRefusalCode} so a consumer routes on the code,
* NEVER on the message string. The one arm throw today is `plan-name-in-use` (the F4
* live-name collision): arming would clobber the by-name ledger, so — unlike the
* per-statement `unknown-series` refusal, which is returned as DATA — it fails the
* whole document. `refusals`, when present, carries the underlying coded refusals a
* driver aggregated into this throw (so nothing is silently dropped at the boundary).
*/
export class ArmError extends Error {
code;
refusals;
constructor(code, message, refusals) {
super(message);
this.name = "ArmError";
this.code = code;
if (refusals !== undefined)
this.refusals = refusals;
}
}
/**
* The stable lead marker of the `adoption-bound-nothing` notice (kestrel-c25w) — the byte prefix of the
* message {@link PlanEngine.armDocument} emits as a PLAN-lifecycle reason (and mirrors onto
* {@link ArmReport.notices} / `validateArm.warnings`) when a valid `ARM … foreach held leg` adopter binds
* ZERO held legs. A consumer that has only the projected lifecycle reason (the day report carries no
* separate code on a lifecycle step) matches on THIS constant instead of hardcoding the prose, so the
* default `day` terminal can surface the notice on the plan's own line (kestrel-gjx5 residual). The
* message begins with this exact string; do not reword it without updating the constant.
*/
export const ARM_BOUND_NOTHING_MARKER = "ARM bound nothing";
/**
* Aggregate one-or-more per-statement {@link ArmRefusal}s into a single throwable {@link ArmError}
* for a session-driver boundary that refuses the WHOLE document (sim/paper): the message enumerates
* EVERY refusal (nothing silently dropped) and the error carries the structured `refusals` + the
* first refusal's `code` so a consumer routes on the code, never on the message. Callers pass a
* non-empty list.
*/
export function armRefusalError(refusals) {
const code = refusals[0]?.code ?? "unknown-series";
const message = `arm: ${refusals.length} statement(s) refused — ` + refusals.map((r) => `[${r.code}] ${r.message}`).join(" | ");
return new ArmError(code, message, refusals);
}
/**
* Derive the directional far-OTM guard evidence for a leg at submission — the engine-side
* {@link OrderGuardEvidence} projection of the {@link import("../fill/index.ts").GuardedIntent}
* verdict authored by the ONE owner (`guardIntent`, fill/guarded-intent, kestrel-vav2). The
* classification, decision-time spot resolution, and the fail-closed fork all live in that owner —
* this function is the thin adapter that strips the intent's `side` for the engine's evidence shape,
* so the far-OTM standalone SELL cap, the covered-wing exemption, and the BUY crossing unlock all run
* END TO END through the fill model. Pure (no clock/RNG).
*
* - **BUY** — the guard only ever UNLOCKS a far-OTM bid (crossing-print), never a bounded-risk leak,
* so an unresolvable spot is safe: classify when we can, else take the symmetric path (no moneyness).
* `covered` is not meaningful for a buy.
* - **SELL** — the engine's never-naked boundary authors only single-leg closes of a held long, which
* are STANDALONE offers in the fill-model sense (`covered: false`) — a genuine multi-leg
* combo-anchored short is not authored here (a FORK; if one is ever authored it must set
* `covered: true` explicitly). If spot is unresolvable we cannot classify the wing, so we **fail
* closed** to the conservative far-OTM cap (`deep_otm`, standalone) and flag it — NEVER a silent
* default and never a silent bypass (BOUNDED-RISK / never-naked, RUNTIME §8).
*/
export function deriveOrderGuard(side, spot, strike, right) {
const g = guardIntent({ side, strike, right, resolveSpot: () => spot });
return {
...(g.moneyness !== undefined ? { moneyness: g.moneyness } : {}),
...(g.covered !== undefined ? { covered: g.covered } : {}),
unresolved: g.unresolved,
};
}
const EPS = 1e-9;
const DEFAULT_SPEC = { tickSize: 0.01, strikeStep: 1, multiplier: 1 };
/** The bare `spot` market-fact ref — resolved through the engine's composed provider to feed the
* `spot` PRICE anchor (ADR-0030 / kestrel-ipc) from EXACTLY the value the `spot` SERIES reads
* (`CanonicalState.spot`), so the two readings of `spot` can never diverge (no two-truths). */
const SPOT_SERIES_REF = { kind: "series", segments: [{ name: "spot" }] };
/**
* A {@link SeriesProvider} that routes **org** paths to the engine's ledger-backed
* {@link OrgFacts} and everything else (market scalars, windowed metrics, baselines, phase, the
* detector/fill/calendar hooks) to the injected market provider. This lets the engine own the
* org side (so `plan(x).state`, `pnl`, `fills.*` are always current as of the swept event)
* without the caller having to wire the engine's org facts into the provider before the engine
* exists (a construction-order cycle).
*/
class ComposedProvider {
market;
org;
registry;
onUnresolved;
constructor(market, org,
/** The ONE shared phonebook (cza.1) the market-vs-org decision dials — the same table
* `series/provider` routes through. Routing through it (instead of an engine-private mirror of
* the market-name set) means the engine's org/market split can never skew from the provider's. */
registry,
/** Stash a fail-closed reason for the sweep in progress — the SAME latch
* {@link EngineOrgFacts} feeds for an unresolvable org path, so an UNKNOWN raised HERE also
* turns a silent non-fire into a loud, edge-latched de-arm (RUNTIME §3/§8). Optional so a
* bare ComposedProvider (tests) still constructs. */
onUnresolved = () => { }) {
this.market = market;
this.org = org;
this.registry = registry;
this.onUnresolved = onUnresolved;
}
isMarket(ref) {
if (ref.window !== undefined)
return true; // windowed ⇒ a market metric (windowed org not v1)
const head = ref.segments[0];
if (ref.segments.length !== 1 || head === undefined || head.selector !== undefined)
return false;
// Single source of truth: a bare name is a market fact iff the phonebook says so (cza.1) — no
// second market-name list to drift from `series/provider`.
return this.registry.lookup(head.name)?.kind === "market";
}
resolve(ref, now) {
return this.isMarket(ref) ? this.market.resolve(ref, now) : this.org.resolve(ref.segments);
}
/** A quantified operand (`children(any|all).<fact>`) is always an org path (a leading `sel-quant`
* selector ⇒ not a bare market name) — resolve it to the per-member vector through the org
* backing so the trigger folds the comparator per member (∃/∀). UNKNOWN (fail-closed) if the org
* backing does not implement quantification. */
quantify(ref, _now) {
return this.org.quantify?.(ref.segments) ?? UNKNOWN;
}
baseline(ref, stat) {
return this.market.baseline(ref, stat);
}
phase() {
return this.market.phase();
}
structural(ev, now) {
return this.market.structural ? this.market.structural(ev, now) : UNKNOWN;
}
/**
* Fill-lifecycle telemetry (`WHEN filled`, `rejected`, `cancelled`).
*
* A LEG QUALIFIER (`filled leg 1`) is REFUSED here, before the provider hook is consulted
* (kestrel-s3h8). Every fill-telemetry provider that ships today — the sim's session-scoped
* `FillTelemetry`, the IBKR feed's — answers from a session-wide latch with no per-plan / per-leg
* scoping, so passing the qualifier through would silently WIDEN the author's intent: a plan armed
* on "my own leg 1 filled" would fire on a STRANGER's fill elsewhere in the session (cross-plan
* bleed). That is the silent default doctrine forbids, and SURFACES.md already states these read
* UNKNOWN. So: UNKNOWN **with a logged reason**, which de-arms the plan loudly instead of leaving
* it armed on a trigger that means something other than what it says. The real capability
* (per-plan/per-leg scoping) lands with kestrel-qbwo.2; when a provider can honour the qualifier
* it is admitted here, not by deleting the refusal.
*/
fillEvent(ev) {
if (ev.leg !== undefined) {
this.onUnresolved(perLegFillQualifierReason(ev.event, ev.leg));
return UNKNOWN;
}
return this.market.fillEvent ? this.market.fillEvent(ev) : UNKNOWN;
}
timeOfDayMinutes(now) {
return this.market.timeOfDayMinutes ? this.market.timeOfDayMinutes(now) : UNKNOWN;
}
}
/** A short, human display name for a {@link SeriesRef} operand — the dotted segment path, with a `(…)`
* window suffix when the ref is windowed (`velocity` + a 5-minute window ⇒ `velocity(5m)`). Used to NAME
* the unwritten operands in a `trigger never resolved` ttl-expiry reason (kestrel-y6vo). Deliberately a
* compact label (not the full canonical printer): it names WHICH series the tape never wrote. */
function seriesDisplayName(ref) {
const path = ref.segments.map((s) => s.name).join(".");
const w = ref.window;
return w === undefined ? path : `${path}(${w.value}${w.unit})`;
}
/** Every bare {@link SeriesRef} operand a trigger references, collected recursively — the arm-time
* input to the unknown-series integrity check (kestrel-mte). Mirrors {@link findMarkInTrigger}'s
* structural walk: only `cmp`/`cross`/`event` carry operands; the compound/decorator nodes recurse
* into their sub-triggers; `phase`/`time-window`/`fill` reference no named series. */
function collectSeriesOperands(t, out) {
switch (t.kind) {
case "cmp":
case "cross":
if (t.left.kind === "series")
out.push(t.left);
if (t.right.kind === "series")
out.push(t.right);
return;
case "event": {
const of = t.of;
if (of?.kind === "series")
out.push(of);
return;
}
case "break-hold":
case "within":
case "until":
case "at":
collectSeriesOperands(t.inner, out);
return;
case "nth":
collectSeriesOperands(t.event, out);
return;
case "not":
collectSeriesOperands(t.term, out);
return;
case "and":
case "or":
for (const term of t.terms)
collectSeriesOperands(term, out);
return;
case "phase":
case "time-window":
case "fill":
// A 0DTE time-stop references the clock / hold-duration, never a named series — no operand to collect.
case "held-stop":
case "clock-stop":
return;
default:
return assertNever(t, "collectSeriesOperands");
}
}
/** Normalize a date selector's RAW digit text for comparison — `2026-1-5` and `2026-01-05` name the
* same day and must not read as a contradiction. The raw text is preserved in the AST (the printer's
* round-trip depends on it), so the padding is applied HERE, at the comparison, and nowhere else. */
function normalizeExpiryDate(d) {
const parts = d.split("-");
if (parts.length !== 3)
return d;
return `${parts[0].padStart(4, "0")}-${parts[1].padStart(2, "0")}-${parts[2].padStart(2, "0")}`;
}
/** Does a book whose tenor is `have` satisfy an authored `want` selector? A pure predicate — no clock,
* no calendar, no ambient date (kestrel-ih5h seam 1).
*
* Three cases, each fail-closed in the sense that matters — the function never GUESSES:
* - `want === undefined` — the author named no tenor, so nothing can contradict it. Admit.
* - `have === undefined` — the book does not state its tenor, so it cannot contradict the author
* either. Admit: inventing a tenor for it would be exactly the silent default this forbids.
* - `want` is an absolute DATE — comparable to the book's date with no calendar at all. This is the
* one case that can be DECIDED, so it is the one case that is enforced.
*
* A RELATIVE selector (`0dte`) or a TAG (`weekly`) cannot be decided here: `0dte` means "expiring on
* the session's date", and the session's date lives behind the clock seam this module must not reach
* across. Admitting them preserves today's behavior exactly (every single-tenor plan we grade prices
* as it always has) and leaves the resolution to the half of this seam that owns the calendar. That
* residue is real and is named in the handoff — it is not a claim that relative tenors are checked. */
function expiryAdmits(want, have) {
if (want === undefined || have === undefined)
return true;
if (want.kind === "expiry-date")
return normalizeExpiryDate(want.date) === normalizeExpiryDate(have);
return true;
}
export class PlanEngine {
#instruments = new Map();
#execDefault;
#signalDefault;
#provider;
#newEvaluator;
#gate;
#bus;
#bucket;
#rUsd;
#fairTau;
/** The tape's complete regime-scope vocabulary when the caller pre-scanned the full bus
* ({@link PlanEngineOptions.tapeRegimeScopes}), else undefined (live: the future is unknown). */
#tapeRegimeScopes;
#ledger;
#registry;
#orgFacts;
/** The path-naming de-arm reason stashed by {@link EngineOrgFacts} the last time a WHEN sweep hit
* a structurally-unresolvable org path — read (and cleared) around each plan's WHEN evaluation in
* {@link #fire} to turn a SILENT non-fire into a de-arm with a logged reason. First-write-wins per
* sweep, so the reason is deterministic. */
#lastOrgUnresolved = null;
#plans = [];
#childIndex = new Map();
#childEvals = new Map();
#envelopes = [];
/** The engine-MINTED fallthrough envelope (kestrel-eywk) — created only when the first-armed document
* declares no POD and no BOOK. It is the ONLY envelope the engine may restate, precisely because it is
* the only one the AUTHOR did not write. Identity-tracked (not matched by name) so a document that
* happens to name a Book "default" can never be mistaken for it. */
#defaultEnvelope = null;
/** Option books re-keyed by `symbol → expiryKey → book` (kestrel-ih5h.2), so two tenors of one
* underlier COEXIST (a calendar) instead of latest-wins clobbering under a symbol-only key. The
* inner key is the BOOK event's own `expiry` string (`""` when the tape pins none); {@link foldBook}
* stays latest-wins WITHIN one expiry — its shape is deliberately unchanged (the collision fence
* with kestrel-snq0). A leg's authored tenor selects its OWN expiry's book in {@link #bookFor}. */
#books = new Map();
/** The last observed spot NBBO per instrument (ADR-0017): the additive `bid`/`ask` riding a
* SPOT tick, plus the print itself as `last`. The quote a spot leg's price ctx reads — an
* option tape sets neither side, so its entries stay dark and no spot leg can price. */
#spotQuotes = new Map();
#tags = new Map();
#now;
#orderSeq = 0;
#armSeq = 0;
/** Monotonic registration counter — the deterministic ordinal that mints each Plan-instance identity
* (kestrel-22j.15). Increments once per {@link #registerPlan}, so the SAME documents armed in the SAME
* order yield byte-identical instance ids across replays (no wall clock / no RNG, RUNTIME §0). It is the
* only thing that distinguishes a legal same-name replacement from its predecessor. */
#planInstanceSeq = 0;
/** Count of actual cancel/replace REPRICE submissions (peg drift + esc-stage boundary) — the
* honest reprice tally the report exposes (F6). Distinct from `escStage`, which is the ladder
* rung an order occupies, not a count of reprices. */
#repriceCount = 0;
constructor(opts) {
for (const spec of opts.instruments)
this.#instruments.set(spec.symbol, spec);
this.#execDefault = opts.instruments.find((s) => s.role === "exec")?.symbol ?? opts.instruments[0]?.symbol;
this.#signalDefault = opts.instruments.find((s) => s.role === "signal")?.symbol ?? opts.instruments[0]?.symbol;
this.#newEvaluator = opts.newEvaluator ?? (() => new TriggerEvaluator());
this.#gate = opts.gate;
this.#bus = opts.busWriter;
this.#bucket = opts.repriceBucket;
this.#rUsd = opts.rUsd;
this.#fairTau = opts.fairTauYears;
this.#tapeRegimeScopes = opts.tapeRegimeScopes !== undefined ? new Set(opts.tapeRegimeScopes) : undefined;
this.#now = opts.now;
this.#ledger = new BookLedger((instrument) => this.#specOf(instrument).multiplier);
this.#registry = opts.registry ?? defaultSeriesRegistry;
// The engine owns the mutable de-arm stash; the org read view stays pure and only reports the
// reason (fail-closed loud de-arm, RUNTIME §8). First unresolvable path per sweep wins.
this.#orgFacts = new EngineOrgFacts(this.#ledger, (reason) => {
this.#lastOrgUnresolved ??= reason;
});
this.#provider = new ComposedProvider(opts.seriesProvider, this.#orgFacts, this.#registry, (reason) => {
this.#lastOrgUnresolved ??= reason;
});
}
/** The engine's ledger-backed org facts (RUNTIME §2): `pnl`, `fills.*`, `plan(x).*`,
* `children(any|all).*`. Exposed for panes/grade columns and inspection; the engine's own trigger
* sweep reads it internally. */
get orgFacts() {
return new EngineOrgFacts(this.#ledger);
}
/**
* The PM-node write seam for a child's published aggregate — the write side of
* `children(any|all).<fact>` (RUNTIME §5 / CONTEXT: Pod). A parent Pod receives each child's own
* org aggregate (its `drawdown`, its `pnl`, …) and folds it into the book ledger so a PM wake can
* quantify across the pod (`children(any).drawdown > 0.3R`). Registers the child-scoped org path
* in the shared phonebook on first write, source-stamped to the child (implicit registration,
* cza.1). Deterministic (a pure upsert); the value is the child's own already-computed aggregate,
* so this seam is taste-free — it does not recompute or judge it.
*/
noteChildFact(child, fact, value) {
this.#ledger.setChildFact(child, fact, value);
this.#registry.noteOrgWrite([{ name: "children", selector: { kind: "sel-quant", q: "any" } }, { name: fact }], child);
}
/** The engine's composed {@link SeriesProvider} — the seam its trigger sweep reads through,
* routing org paths to the ledger and market facts to the injected provider via the shared
* phonebook. Exposed read-only for inspection (e.g. certifying that the engine and the provider
* agree on market-vs-org for every declared series — the anti-skew guarantee, cza.2). */
get seriesProvider() {
return this.#provider;
}
// ── arming ────────────────────────────────────────────────────────────────
/**
* Arm a parsed Kestrel document (RUNTIME §5). Builds the document's nesting envelope chain
* (Pod `R`-risk-budgets → Book budget/concurrency; a single-book document is a chain of one),
* registers each plan `authored`, then immediately reconciles arming at the current time: a
* plan whose regime gate is satisfied (or `regime {any}`/absent) arms; one gated on a tag not
* yet written stays authored until a `REGIME` write (UNKNOWN de-arm, RUNTIME §3).
*/
armDocument(mod, opts) {
// Two arm-time integrity checks, one per shape (OSS-ADR-0045):
// - F4 (same-name supersession shadowing): a revision re-declaring a name still owned by a LIVE
// (not-done) prior record would clobber the by-name ledger and garble the merged lifecycle
// trace (`plan(x).state`, the frame's plan lines). This THROWS an {@link ArmError} (code
// `plan-name-in-use`) — a whole-document refusal, because arming would corrupt LIVE state; the
// thrown error carries `.code`, never matched by message. A name whose prior record is already
// `done` (fully expired/settled) is free to reuse.
// - unknown-series (mte): a trigger names a case-variant of a known market fact. This is a
// per-statement DATA refusal collected below — the offending statement is skipped (never armed:
// arming it would read UNKNOWN forever, the silent de-arm this reverts), its healthy siblings
// arm. Nothing healthy is aborted by a bad sibling; nothing is silently dropped.
const refusals = [];
for (const st of mod.statements) {
if (st.kind !== "plan")
continue;
const clash = this.#plans.find((pr) => pr.name === st.name && !pr.done);
if (clash !== undefined) {
throw new ArmError("plan-name-in-use", `arm: plan name ${JSON.stringify(st.name)} still owned by a managing plan — names are lineage; author a new name (fail-closed, RUNTIME §8)`);
}
const rejection = this.#unknownSeriesRejection(st);
if (rejection !== null) {
refusals.push({ statement: st.name, code: "unknown-series", message: rejection.message, repair: rejection.repair });
}
// Manages-nothing (kestrel-b4wx/kestrel-hcnj): a covered sell with no way to ever hold inventory is
// refused AS DATA — never registered, healthy siblings still arm — so it can never silently arm inert.
const manages = this.#managesNothingRejection(st);
if (manages !== null) {
refusals.push({ statement: st.name, code: "manages-nothing", message: manages.message });
}
// Opening-short (kestrel-53gw): an AUTHORED plan whose `DO`/`ALSO` entry opens a SELL leg it can never
// cover at fire is refused AS DATA — so a debit spread whose short leg would silently drop (minting a
// proof byte-identical to a naked long) fails closed LOUD at arm instead. A synthesized one-shot
// placeOrder is EXEMPT (it has its own terminal de-arm at fire, tests/simulate.order-actions.test.ts).
if (!(opts?.synthesizedOrder ?? false)) {
const openShort = this.#openingShortRejection(st);
if (openShort !== null) {
refusals.push({ statement: st.name, code: "opening-short-uncovered", message: openShort.message });
}
}
}
// The healthy view: refused statements are filtered out so their name/budget never enter the
// standing book (envelope construction, registration, and `plan(x).state` all read `healthy`).
// With no refusals `mod` passes through UNCHANGED — the healthy arm path stays byte-identical.
const refused = new Set(refusals.map((r) => r.statement));
const healthy = refused.size === 0
? mod
: { ...mod, statements: mod.statements.filter((st) => st.kind !== "plan" || !refused.has(st.name)) };
const planEnvelope = this.#buildEnvelopes(healthy, opts?.synthesizedOrder ?? false);
const registered = [];
for (const st of healthy.statements) {
if (st.kind !== "plan")
continue;
registered.push(this.#registerPlan(st, planEnvelope, opts?.synthesizedOrder ?? false));
}
// Cross-Plan inventory-ownership handshake (kestrel-22j.14): a replacement plan carrying an
// explicit ARM inventory binding adopts management authority for the exact held Book legs from
// superseded, acquisition-halted, inventory-holding siblings — HERE, at the tail of arming
// (after #registerPlan, before #reconcileArming), inside the guaranteed supersede-then-arm
// window so the adopted long is in place before any onEvent can fire a covered sell.
this.#adoptInventory(registered, this.#now);
this.#reconcileArming(this.#now);
// Adoption-management promotion (kestrel-r866). A PURE adopter — an `ARM … foreach held leg` binding
// with no fireable entry — has now been armed by #reconcileArming, but it will never reach the fire
// path, so its EXIT/TP would sit inert while it holds the adopted leg (the tracer-7 "escape hatch is
// INERT on the day handshake" defect). Promote it armed→managing so its authored management clauses
// run. Scoped to `entries.length === 0` (a plan WITH entries reaches managing through the fire path
// and is untouched) and to plans that actually took inventory (`adoptedInventory`) and armed cleanly.
for (const pr of registered) {
if (!pr.adoptedInventory || pr.done || pr.fired || pr.state !== "armed" || pr.entries.length > 0)
continue;
this.#beginAdoptedManagement(pr, this.#now);
}
// Accept-path activation-gate visibility (kestrel-ocyf). A plan gated on a regime SCOPE with no
// writer on this tape reconciles to `authored (blocked: regime <scope> UNKNOWN)` and stays there
// forever — it can never arm, never fire. Before this the block was SILENT at submit: no reject,
// no bus notice, visible only a full wake later in the frame's plans section, so an agent read
// `authored` as a live position (the phantom-position trap; tracer-6 lost its best entry to it).
//
// Two grades of signal, and the notice must never claim more than the engine can prove:
// - With {@link #tapeRegimeScopes} (the caller pre-scanned the full bus — sim/replay/day): a gate
// scope OUTSIDE the tape's regime vocabulary is PROVABLY dead — the definitive verdict. A scope
// the tape WILL write later is legitimately waiting and arms when the write replays — no notice
// at all (a warning there would cry wolf on every regime-gated plan, worse than silence).
// - Without it (live: the future is unknown): a CONDITIONAL notice — the scope is not yet
// written, the plan stays blocked until a REGIME event writes it. Never the word "never".
//
// A WARNING, not a throw, even for the provably-dead grade — three reasons over the mte precedent
// (#refuseUnknownSeries, which THROWS): (1) mte refuses typos of the FROZEN market vocabulary
// (zero legitimate authorings); a regime gate on a regime-less tape is a legal cross-mode pattern
// (the shipped e2e corpus deliberately co-arms `gated-idle` beside four live plans — a standing
// gated plan whose feed exists live but not on this sim tape). (2) an armDocument throw is
// WHOLE-document: it would take down live sibling plans for a per-plan condition. (3) every
// arm-time throw must also be previewed by {@link supersedeArmRejection} or a doomed revision
// tears down the standing book before the arm detonates (the p48s day-swallow bug) — a notice
// has no such blast radius.
//
// The notice is surfaced TWO ways from the SAME string, byte-for-byte (kestrel-hk9u): emitted on
// the bus (the run-time plan-lifecycle trace — what a real batch run's blotter/frame shows) AND
// collected onto {@link ArmReport.notices}, so a bus-less arm preview (`validateArm`, whose Bus is
// a no-op sink) can surface the identical verdict. This closes the validate-gap the persona probe
// hit (kestrel-hk9u): a regime-gated flagship example that `validate --arm` called "arms clean"
// while every free cell placed 0 orders — now the dead-on-arrival gate is legible AT ARM.
const notices = [];
for (const pr of registered) {
if (pr.state !== "authored")
continue;
const gate = pr.plan.regime;
if (gate === undefined)
continue;
const unknown = gate.tags
.filter((b) => b.scope !== "any" && b.value !== "any" && this.#tags.get(b.scope) === undefined)
.map((b) => b.scope);
if (unknown.length === 0)
continue;
let message;
if (this.#tapeRegimeScopes !== undefined) {
const dead = unknown.filter((s) => !this.#tapeRegimeScopes.has(s));
if (dead.length === 0)
continue; // every gated scope IS written later — legitimately waiting
message =
`regime ${dead.join(", ")} is never written on this tape — this plan can never arm ` +
`(dead on arrival; it stays authored (blocked) for the whole session)`;
}
else {
message =
`regime ${unknown.join(", ")} not yet written — plan stays authored (blocked) until a ` +
`REGIME event writes it; on a tape with no ${unknown.join("/")} writer it can never arm`;
}
this.#emitReject(this.#now, pr, message);
notices.push({ statement: pr.name, code: "regime-unsatisfiable", message });
}
// Adoption-bound-nothing visibility (kestrel-c25w, the run-tier sibling of the b4wx footgun). A VALID
// `ARM [basis <px>] foreach|any held leg` adopter (its `armClause.over` is defined, so it PASSES the
// parse-tier manages-nothing refusal — it genuinely CAN hold inventory) that, at THIS arm, resolved to
// ZERO held legs bound nothing: no superseded, acquisition-halted, inventory-holding sibling had a leg
// to hand off, so #adoptInventory left `adoptedInventory` false. Its whole job was adopt-and-exit, so
// binding nothing means its exit protection is INERT — yet the plan arms, expires quietly, and grades
// GREEN (`plans_armed=1 … orders_placed=0`), indistinguishable on the run report from a working
// adoption. This is the SAME silent one-way-door class b4wx targeted, surviving at the RUN/grade tier
// for a plan the PARSER cannot refuse — it is a legitimate adopter that simply found nothing to adopt in
// this session (a DIFFERENT session, with a superseded holder in place, would bind), so this is a
// NOTICE, never a refusal (the document is integrity-clean; it no-op'd here).
//
// Surfaced TWO ways from the SAME string, byte-for-byte (the kestrel-hk9u pattern the regime notice
// above uses): emitted on the bus as a PLAN-lifecycle reason (so the frame / human report shows it on
// the plan's OWN line — the bus-only notice is what a real batch run's blotter/frame carries) AND
// collected onto {@link ArmReport.notices} (so a bus-less arm preview — `validateArm`, `parse --arm`,
// the hosted `/validate` — surfaces the identical verdict). Before this the zero-leg bind was pure
// silence: not even a bus reason, so an author could not tell adoption succeeded from silently no-op'd.
for (const pr of registered) {
if (pr.done || pr.armClause?.over === undefined || pr.adoptedInventory)
continue;
const message = `${ARM_BOUND_NOTHING_MARKER} — this \`ARM … foreach held leg\` adopter found no superseded, inventory-holding ` +
`plan to adopt from, so it bound 0 held legs and its exit protection is INERT (it stays armed, then ` +
`expires without ever covering). Adoption is a one-shot at arm: supersede the inventory-holding ` +
`plan in the SAME step so its held leg is in place to bind (the day handshake, RUNTIME §4).`;
this.#emitReject(this.#now, pr, message);
notices.push({ statement: pr.name, code: "adoption-bound-nothing", message });
}
return { armed: registered.map((pr) => pr.name), refusals, ...(notices.length > 0 ? { notices } : {}) };
}
/**
* Arm-time unknown-series integrity (kestrel-mte / OSS-ADR-0045) as a PURE preview (no mutation, no
* throw): the coded refusal a case-variant market-series trigger raises at arm — its DISPLAY
* `message` and a concrete did-you-mean `repair` (the canonical lowercase series name) — or `null`
* when every trigger operand resolves.
*
* The failure it catches: a trigger operand names a CASE-VARIANT of a registered market fact
* (`VELOCITY` for the windowed `velocity`, `VWAP` for the scalar `vwap`). Because
* {@link SeriesRegistry.lookup} is now EXACT-MATCH (OSS-ADR-0045; the 0.4.5 case-insensitive
* forgiveness is reverted), that name has NO phonebook record, so the org/market split routes it to
* the ORG side where it reads UNKNOWN forever: the plan would arm but never fire, its only runtime
* signal a MISLEADING "org path unresolvable — no such org fact". The platform market vocabulary is
* FROZEN and known at arm time (the shared phonebook, `this.#registry`), so this is STATICALLY
* knowable: {@link armDocument} refuses the offending statement AS DATA (naming the offending token
* AND the intended market series), its healthy siblings still arm — never arming a plan that can
* never fire, never silently de-arming it at runtime (RUNTIME §8).
*
* Scoped narrowly to a case-variant near-miss of a KNOWN market name (zero false positives — a
* genuine org fact like `pnl` never case-matches a market name); a truly unknown bare name with no
* market near-match stays an org path (org facts register implicitly / delegate to the book). Split
* out so {@link supersedeArmRejection} can preview it — a preview that misses it lets a doomed
* revision tear down the standing book before the arm (the p48s day-swallow bug).
*
* DELEGATES to the ONE shared LIGHT implementation ({@link unknownSeriesRejection}, `src/validate`),
* so this arm path and the hosted `/validate` route run the SAME semantic check — never a second
* drifting copy (kestrel-h0ax; the mei parse ≠ arm bug came from that duplication).
*/
#unknownSeriesRejection(st) {
return unknownSeriesRejection(st, this.#registry);
}
/**
* Arm-time MANAGES-NOTHING integrity (kestrel-b4wx / kestrel-hcnj) as a PURE preview (no mutation, no
* throw): the coded refusal a plan that carries a covered sell it can never cover raises at arm — its
* DISPLAY `message` — or `null` when the plan can hold inventory.
*
* The failure it catches: a plan carries an `EXIT`/`TP` (a covered sell — both can ONLY ever sell
* inventory the plan HOLDS) but has NO way to ever hold inventory. Such a plan arms and sits inert
* forever: an entryless plan never fires, so its EXIT is never even evaluated — the never-naked
* refusal never gets to speak, and the held leg an EXIT-only revision meant to protect stays silently
* unreachable. This is the SAME silent one-way door r866 fixed, reached one dropped keyword away (a
* revision that wrote `EXIT …` but forgot the `ARM … foreach held leg` adoption binding). b4wx surfaced
* it as a bus-only arm-time NOTICE, which is invisible on the author path (`parse --arm` still returned
* armed=true) and never blocked arming — the footgun re-locked. Now it is refused AS DATA (like the mte
* unknown-series refusal): the offending statement never registers, its healthy siblings still arm, and
* `parse --arm`, `/validate`, and a real run all fail closed identically.
*
* A plan CAN hold/manage inventory iff ANY of: (1) an entry clause (`DO`/`ALSO`/`RELOAD`) acquires it;
* (2) an `ARM … foreach|any held leg` binding adopts a superseded plan's held leg; (3) a covered sell
* ITSELF carries a `foreach|any held leg` quantifier and so binds the held legs it iterates (the
* `manage-inventory` golden: `ARM WHEN …` + `TP … foreach held leg` + `EXIT … foreach held leg`). Only a
* covered sell with NONE of these three can never cover — that, and only that, is refused. A bare
* `ARM WHEN` chain (no held quantifier) does NOT hold inventory on its own (RUNTIME §4), so it does not
* rescue a bare `EXIT`. Kept as a SEMANTIC (cross-clause) arm-tier check, NOT a parse-tier one: the
* grammar round-trip property legitimately generates inert-but-well-formed ASTs, and a covered sell IS
* grammatically valid — the defect is that it can never cover, which is an arm-integrity fact.
*
* Split out (like {@link #unknownSeriesRejection}) so {@link supersedeArmRejection} can preview it — a
* preview that misses it lets a doomed EXIT-only revision supersede the standing book, then get refused
* at arm, tearing the book down (the p48s day-swallow bug). PURE: reads only `st.clauses`.
*/
#managesNothingRejection(st) {
const covered = st.clauses.find((c) => c.kind === "exit" || c.kind === "tp");
if (covered === undefined)
return null; // no covered sell ⇒ nothing to cover, nothing to refuse
const hasEntry = st.clauses.some((c) => c.kind === "do" || c.kind === "also" || c.kind === "reload");
const armAdopts = st.clauses.some((c) => c.kind === "arm" && c.over !== undefined);
const coverAdopts = st.clauses.some((c) => (c.kind === "exit" || c.kind === "tp") && c.over !== undefined);
if (hasEntry || armAdopts || coverAdopts)
return null;
const clause = covered.kind === "exit" ? "EXIT" : "TP";
return {
message: `arm: plan ${JSON.stringify(st.name)} carries a covered ${clause} but can never hold inventory — no ` +
`\`DO\`/\`ALSO\`/\`RELOAD\` entry to acquire it and no \`ARM … foreach held leg\` to adopt a superseded ` +
`plan's held leg — so it manages NOTHING and can never cover (fail-closed at arm, RUNTIME §4/§8; a ` +
`plan that can never cover would arm and sit inert forever). Did you mean \`DO buy …\` to acquire the ` +
`leg, or \`ARM [basis <px>] foreach held leg\` to adopt the held leg this ${clause} would exit?`,
};
}
/**
* Arm-time OPENING-SHORT-STRUCTURE integrity (kestrel-53gw) as a PURE preview (no mutation, no throw): the
* coded refusal a MULTI-LEG opening structure raises at arm when its uncoverable SELL leg would silently
* collapse into a misleading long-only proof — its DISPLAY `message` — or `null` when the structure is safe.
*
* The failure it catches (the debit-spread silent-collapse): an author writes a defined-risk vertical —
* `DO buy 2 +1 C @ ask` / `ALSO sell 2 +3 C @ bid`. The short leg's ONLY coverage at the plan's single
* entry fire is inventory the plan already HOLDS, and it holds none: a same-fire entry BUY has not filled
* yet (fills return as later bus events, {@link #netHeldForLeg} counts `filledQty`), and cross-strike
* STRUCTURAL coverage — the long +1 C bounding the short +3 C to a defined debit — is a genuine combo
* concept that needs ATOMIC multi-leg execution the engine does not yet do (the AST `atomic` keyword is
* reserved-but-refused on every surface; the whole-structure preflight + atomic adapter is future work).
* So {@link #sellCovered} refuses the short at every fire ("uncovered sell refused: never naked") while the
* plan's BUY leg FIRES — silently degrading the spread to a naked long whose graded proof is BYTE-IDENTICAL
* to the single long leg (order_count/fill_count/pnl all equal), with the refusal buried on an `armed`
* lifecycle line the headline proof never surfaces. That is the zero-diagnostic misleading proof
* kestrel-53gw reports.
*
* SCOPED PRECISELY to that byte-identical-proof defect: the refusal fires only when an uncoverable opening
* SELL leg CO-EXISTS with an opening BUY leg (`DO buy …`, incl. a spot leg) — the exact shape where the buy
* fires alone and mints a proof indistinguishable from a valid long (debit spread → naked long; credit
* spread → naked long wing; single-entry covered call → naked long shares). A PURE naked opening sell (a
* `DO sell …` with NO opening buy anywhere) is deliberately LEFT to the fire-time never-naked boundary
* ({@link #sellCovered}): its proof is `order_count = 0` — already DISTINGUISHABLE from a long and already
* carrying a per-fire `uncovered sell refused: never naked` lifecycle diagnostic (ADR-0017; the equity
* naked-short in tests/e2e.equity.test.ts). It is not the misleading-proof bug, so we do not move its
* refusal earlier and change that established surface.
*
* A plan CAN cover an entry sell at fire iff it ADOPTS pre-existing inventory: an `ARM … foreach|any held
* leg` binding, or an `EXIT`/`TP … foreach|any held leg` cover that itself binds the held legs. A structure
* with NEITHER (and no held inventory) is dead on arrival — refused AS DATA (like
* {@link #managesNothingRejection}), so `parse --arm`, `/validate`, and a real run all fail closed
* identically and the misleading proof can never mint. An adopting plan is left to the fire-time
* {@link #sellCovered} boundary (unchanged).
*
* SCOPED to authored plans (the caller skips it for a synthesized one-shot `placeOrder`, which has its own
* LOUD terminal de-arm at fire, tested by tests/simulate.order-actions.test.ts). Reads only `st.clauses`;
* pure. RELOAD sells are NOT caught — a RELOAD fires during MANAGEMENT, after the entry buy can fill, so it
* is legitimately deferred-coverable.
*/
#openingShortRejection(st) {
const entry = st.clauses.find((c) => (c.kind === "do" || c.kind === "also") && c.legs.some((l) => l.side === "sell"));
if (entry === undefined)
return null; // no entry sell ⇒ nothing that could open uncovered
// The misleading-proof defect requires an opening BUY that fires ALONE and passes for a long. Without one,
// a pure naked opening sell mints order_count=0 (distinguishable) and keeps its fire-time never-naked
// diagnostic — not this bug, left to #sellCovered (ADR-0017; e2e.equity naked-short).
const opensLong = st.clauses.some((c) => (c.kind === "do" || c.kind === "also") && c.legs.some((l) => l.side === "buy"));
if (!opensLong)
return null;
const armAdopts = st.clauses.some((c) => c.kind === "arm" && c.over !== undefined);
const coverAdopts = st.clauses.some((c) => (c.kind === "exit" || c.kind === "tp") && c.over !== undefined);
if (armAdopts || coverAdopts)
return null; // may hold covering inventory at fire — leave to #sellCovered
const clause = entry.kind === "do" ? "DO" : "ALSO";
return {
message: `arm: plan ${JSON.stringify(st.name)} opens a multi-leg structure whose \`${clause}\` SELL leg it can ` +
`never cover — no \`ARM … foreach|any held leg\` and no \`EXIT\`/\`TP … foreach|any held leg\` to adopt ` +
`inventory. An entry sell can only ever CLOSE inventory the plan already holds, and at the entry fire ` +
`it holds none (a same-fire entry BUY has not filled yet). A defined-risk vertical whose long leg would ` +
`structurally cover the short needs ATOMIC multi-leg execution the engine does not yet support ` +
`(kestrel-psn5) — without it the short is refused never-naked at every fire while the long fires alone, ` +
`minting a proof byte-identical to a naked long (fail-closed at arm, RUNTIME §8; kestrel-53gw). Did you ` +
`mean a long-only structure, or \`ARM [basis <px>] foreach held leg\` to adopt the leg this sell closes?`,
};
}
/**
* **Document supersession** (RUNTIME §5): a newly-authored document replaces the standing one.
* Every currently-standing plan is de-armed cleanly at `now` — acquisition is halted and all
* **unfilled** entry/reload children are cancelled — but **filled inventory and its resting
* TP/EXIT management PERSIST** under the superseded plan record (never liquidated). A plan that
* holds no inventory and has nothing working is finished (`done(expired)`, reason `superseded`);
* a plan still holding inventory keeps managing it (TP/EXIT/INVALIDATE) under its old record. The
* caller then {@link armDocument}s the replacement. Ordering with the caller is: supersede first
* (at `now`), then arm — the two documents never both acquire the same tick.
*/
supersede(now) {
this.#now = now;
for (const pr of this.#plans) {
if (pr.done)
continue;
// Halt acquisition + cancel unfilled entry/reload children only (inventory + TP/EXIT ride).
pr.acquisitionHalted = true;
pr.reloadsHalted = true;
for (const child of pr.children) {
if (child.filled || child.cancelled)
continue;
if (child.role === "entry" || child.role === "reload")
this.#cancelChild(child);
}
// No inventory and nothing working ⇒ cleanly de-armed (the common case: an un-fired or
// fully-worked plan). Inventory-holding plans ride under their superseded record. Evaluated
// via the shared survivor predicate (after the entry/reload cancellation above) so it can never
// drift from {@link supersedeArmRejection}'s preview.
if (!this.#survivesSupersede(pr)) {
this.#finish(pr, now, "expired", "superseded");
}
}
}
/**
* Cancel a resting order by its real Gate ref on behalf of an agent `cancelOrder` (kestrel-5zl.5 / #3c).
* Routes through {@link #cancelChild} so BOTH the engine's child state AND the emitted bus are updated
* in the SAME step: the engine dump (which the wake snapshot scrapes `resting[]` from) drops the order