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.
122 lines • 9.6 kB
TypeScript
/**
* # engine/validate — the arm-level validation surface for consumers with no live core (kestrel-bc38.2)
*
* `validateArm(document, ctx)` runs BOTH gates a live open runs — the grammar (`parse`, ADR-0001) AND
* the engine's arm-time integrity ({@link PlanEngine.armDocument}: the kestrel-mte unknown-series
* refusal, envelope construction) — over an authored document WITHOUT running a session (no bus, no
* tick loop, no fill engine, nothing graded). It returns tiered diagnostics instead of throwing, so a
* caller can preview an authoring before committing it.
*
* ## Why one surface (the parse≠arm hole, kestrel-7tbi / p48s)
* Parse-green is NOT arm-green: case is a VOCABULARY fact, not a grammar fact, so a document that
* parses cleanly can still name a series that resolves UNKNOWN forever (`VELOCITY` for the windowed
* metric `velocity`) — it arms then silently never fires. Before this surface every consumer hand-rolled
* its own preview (`fixedPlanAgent`'s construct-time `parse`; the docs arm-gate; the hosted `/validate`
* route, which historically only PARSED — the 422≠run gap). Routing those through ONE function makes the
* diagnostic BYTE-IDENTICAL whether it reaches an agent as a 422 or a human as a failed run: the
* arm-refusal message a consumer shows here is the very message {@link PlanEngine.armDocument} raises on
* the graded path (returned as a coded `unknown-series` refusal, OSS-ADR-0045), because this function
* drives that exact path.
*
* ## What this surface does NOT cover — the fresh-engine horizon (do not "migrate" day.ts here)
* This validates a document against an EMPTY engine, so it sees only the refusals that are knowable
* from the document + context ALONE. It CANNOT see any guard whose input is the LIVE standing book —
* above all the F4 same-name collision guard, which rejects a revision re-declaring a name still owned
* by a live plan (`PlanEngine.armDocument`, RUNTIME §8). A fresh engine owns no plans, so F4 is
* structurally unreachable from here, and two same-name plans in ONE document report `armed: true`
* (F4 fires on a name colliding with a PRIOR record, not on an intra-document duplicate).
* Consequently `day.ts` keeps its own preview through the live core (`previewSupersede` →
* {@link PlanEngine.supersedeArmRejection}): routing it through `validateArm` would SILENTLY DROP F4
* and let a doomed revision tear down the standing book (the p48s day-swallow bug). This function is
* the shared preview for consumers WITHOUT a live core, not a replacement for a live-core preview.
*
* Pure and deterministic (no clock, no RNG, no IO): a fresh throwaway engine over a no-op Gate/Bus and
* a stub series provider — arm reads the phonebook, never a live quote — so the same document + same
* context yields the same diagnostics. Fail-closed: a document only reports `armed` when it would
* ACTUALLY arm (never merely parse), and an under-specified context is REFUSED, never defaulted
* through (see {@link ArmContext.instruments}).
*/
import { type SeriesRegistry } from "../series/index.ts";
import { type InstrumentSpec } from "./plans.ts";
/** Which gate a diagnostic came from — the CALLER's context (`context`), the grammar (`parse`), or the
* engine's arm-time integrity (`armDocument`). The three tiers are checked in that order (an
* under-specified context is never validated; a document that does not parse is never armed), so a
* result carries a diagnostic from at most one tier. */
export type ArmDiagnosticTier = "context" | "parse" | "arm";
/** One tiered validation diagnostic. For `parse` and `arm`, `message` is the diagnostic EXACTLY as the
* corresponding live path surfaces it — the `KestrelParseError` message for `parse`, the thrown
* arm-refusal message verbatim for `arm` — so a consumer relays it unmodified (the
* byte-identical-across-422-and-run contract). A `context` message has NO live-path counterpart: it
* reports a defect in the CALLER's request, not in the authored document, and is worded by this module
* (which is why it is a distinct tier rather than an `arm` diagnostic the byte-identity contract would
* then be lying about). */
export interface ArmDiagnostic {
readonly tier: ArmDiagnosticTier;
readonly message: string;
}
/** The session facts arm-time validation needs — the same inputs {@link PlanEngine} takes to arm,
* minus everything only a RUNNING session supplies (gate, bus, live series). A caller derives
* `instruments` from the session META ({@link import("../session/sim.ts").resolveSpecs}); `registry`
* defaults to the shared phonebook when omitted. */
export interface ArmContext {
/** The tape's COMPLETE regime-scope vocabulary, when the caller knows it (kestrel-hk9u/ocyf) — every
* scope any REGIME event on the target tape writes (`tapeRegimeScopesOf(events)`, src/session/sim.ts).
* A regime gate whose scope is NOT in this set can never arm on that tape, so the arm preview surfaces
* the DEFINITIVE dead-on-arrival notice a real batch run raises — byte-identical (validation=execution).
*
* DEFAULT when omitted: the EMPTY vocabulary (`[]`) — the closed-world verdict for an arm context that
* names no tape (`--instruments`, a bare `--arm`, an agent's construct-time check). This is honest, not
* a hedge: the context the caller handed over carries no regime stream, so a regime-gated plan WILL
* stay `authored (blocked)` on it. It is a NON-FATAL warning (the document still arms), so a caller who
* intends to run on a regime-carrying tape is informed, not blocked — the remedy is to pass that tape's
* scopes (or `--bus <tape>`, which derives them). Unlike a live session's unknown future, a validation
* preview IS a closed world (there is no acting agent and no later REGIME write to leak), so the
* definitive grade is legitimate here — the lookahead-oracle firewall that keeps the interactive path
* conditional (see the header of tests/simulate.regime-notice.test.ts) does not apply to a static preview. */
readonly tapeRegimeScopes?: readonly string[];
/** The session's instruments (exec/signal specs), NON-EMPTY. Arm reads their contract facts
* (multiplier, strike step), never a live quote.
*
* An empty list is REFUSED with a `context`-tier diagnostic, never validated (fail-closed). A driver
* asserts this same precondition — `specs.find(role === "signal") ?? specs[0]!` (`src/session/sim.ts`)
* — so with no instruments there is no signal symbol to anchor canonical state, and the only way to
* proceed would be to invent one (an empty-symbol stub). That stub would answer the caller's actual
* question ("would this document arm in MY session?") with a verdict from a session that does not
* exist: option plans that a real exec spec's `multiplier` would reject can sail through as
* `armed: true`. A permissive verdict on an under-specified request is exactly the silent default
* this repo forbids — so the request is refused instead. */
readonly instruments: readonly InstrumentSpec[];
/** The series phonebook the market-vs-org routing dials (CONTEXT: Registry). Omit for the shared
* default — the same registry a live engine arms against. */
readonly registry?: SeriesRegistry;
}
/** The outcome of {@link validateArm}: `armed` is true iff the document passed BOTH gates (it would
* actually arm, not merely parse); `diagnostics` carries the tiered refusal(s) otherwise; `warnings`
* carries NON-FATAL arm-time notices (kestrel-hk9u) — a document can be `armed: true` and STILL carry
* a warning, when it arms clean but a plan can never FIRE on the arming context (a regime gate whose
* scope this tape never writes). `warnings` is always present (empty when there are none), so a caller
* never has to guard an optional field; it never flips `armed`. */
export interface ArmValidation {
readonly armed: boolean;
readonly diagnostics: readonly ArmDiagnostic[];
readonly warnings: readonly ArmDiagnostic[];
}
/**
* Validate an authored document through BOTH the grammar and the engine's arm-time integrity WITHOUT
* running a session, returning tiered diagnostics ({@link ArmValidation}). The shared preview for
* consumers that have no live core — the docs arm-gate, the hosted `/validate` route, an agent's
* construct-time check — so the diagnostic is byte-identical whether an agent sees a 422 or a human
* sees a failed run. NOT a replacement for a live-core preview: it cannot see the F4 same-name
* collision guard, so `day.ts` keeps `previewSupersede` (see the module header).
*
* Order (fail-closed, first refusal wins): (1) `context` — an under-specified {@link ArmContext} (no
* instruments) is refused outright, because a verdict would have to come from an invented session;
* (2) `parse` — a grammar escape returns a `parse`-tier diagnostic and stops; (3) `armDocument` on a
* fresh throwaway engine built from `ctx` — the mte unknown-series refusal and envelope construction —
* where any throw returns an `arm`-tier diagnostic carrying the thrown message verbatim. A clean pass
* through all three returns `{ armed: true, diagnostics: [], warnings }` — `warnings` carries any
* non-fatal arm notice (a regime gate whose scope the context never writes, kestrel-hk9u) and never
* flips `armed`.
*/
export declare function validateArm(document: string, ctx: ArmContext): ArmValidation;
//# sourceMappingURL=validate.d.ts.map