UNPKG

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
/** * # 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