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.

448 lines (389 loc) 30.2 kB
# SURFACES — concrete syntax decisions (phase 2) Every concrete-syntax choice the ADRs / ARCHITECTURE left open, resolved here for the typed object model (`src/lang/ast.ts`) and canonical printer (`src/lang/print.ts`). One line each, with rationale. This is the review artifact: the ADRs are binding and are not re-litigated; this file is where the *unspecified* got decided. The examples teach language features only, on generic public tickers (SPX/SPY/QQQ) per ARCHITECTURE §7. ## Canonical form, globally - **2-space indentation; one clause per indented line under a statement header.** The ADR examples sometimes group clauses on one line (`PRIORITY 2 COALESCE hod-action`); canonical splits them, in a fixed per-statement clause order, so round-trip is deterministic. Grouping is illustrative, not canonical. - **No trailing newline from `print()`.** File serialization appends one (`.editorconfig` owns final-newline); a bare statement and a module both print without it so tests compare exact bytes. - **Blocks (module header + each statement) are separated by one blank line.** - **Numbers render via minimal JS form** (`String(v)`): `1R`, not `1.0R`; `0.2R`, `0.5R`. The AST value is a `number`, so `1.0` vs `1` is unrecoverable — canonical picks the minimal form. `-0` normalizes to `0`. Non-finite numbers **throw** (fail-closed). - **Strings are double-quoted**, with `\` and `"` escaped. Used for thesis, provenance author/origin/replay, and import paths. - **Identifiers are case-sensitive and printed verbatim** (`spot`, `hod`, `vwap`). There is no case-normalization; the registry's registered spelling is the canonical spelling. The platform market series are **registered lowercase** and matched case-sensitively (`spot`, `vwap`, `hod`, `lod`, `prior_close`, `or_high`, `or_low`), so a case-variant like `HOD`/`VWAP` is NOT a market fact — it routes to the org side, reads UNKNOWN, and is refused at arm (the kestrel-mte unknown-series guard: "did you mean the market series `hod`? … case-sensitive and the platform market facts are lowercase"). Examples MUST use the lowercase registered spelling; the uppercase acronym belongs only in English prose (e.g. "the high of day (HOD)"), never in Kestrel code an author would copy. ## Lexical units - **Windows/durations render tight**: `<number><unit>``1m`, `30s`, `5m`, `+30m`. Units are `s m h d w mo q y`. - **`velocity(1m)`**: the window is printed in parentheses immediately after the series path. A series may carry a window **or** a segment selector (`plan(x)`), never both on the same token, so the paren form is unambiguous. - **Baselines**: percentile prints prefix `p99`; sigma prints suffix `3sigma` — matching the two idioms in ARCHITECTURE §Wake. Only `p` and `sigma` are modeled in v1; other baseline stats are ABSENT (add as needed, not stubbed). - **Quantities** carry an optional unit suffix: `R` (risk fraction), `c` (cents), `%`, `bp`. No unit → bare number. Signs are part of the number (`-0.5R`). - **Time of day**: zero-padded `HH:MM` (`16:00`, `09:30`). ## Series references - **Dotted path**, segments joined by `.`: `regime.intraday`, `alpha.pnl`, `fills.avg_px`. - **Selectors on a segment** print as `name(sel)`: an org quantifier `children(any).drawdown`, or a named target `plan(chase-urgent).state`. - **The market/org distinction is registry metadata, NOT a syntactic property.** Both print identically as a dotted path; the registry (later phase) resolves a path to its space. It is therefore **not a field on `SeriesRef`** — phase 3 removed the earlier `space?` field because it is unrecoverable from canonical text, and ADR-0004 forbids un-round-trippable state on a syntactic node. A bare symbolic value (a plan-state literal like `armed`) is likewise just a single-segment series — text cannot distinguish it, so there is no separate identifier-operand node. - **Plan-state and org-aggregate reads are ordinary series comparisons**, not dedicated trigger nodes: `plan(x).state == armed`, `children(any).drawdown > 0.3R`. This keeps the trigger algebra small and the registry the single source of names. ## Trigger algebra - **Comparison operators are symbolic**: `> >= < <= == !=`. Rationale: 1-token, unambiguous, and `spot > hod` reads cleanly. English relational sugar (`under`/`over`/`above`/`below` with an implicit subject) is a **parse-time alias** (phase 3) that canonicalizes to a symbolic comparison with an explicit subject — e.g. `EXIT under vwap` → canonical `EXIT spot < vwap`. Canonical has no implicit subject. - **`crosses above` / `crosses below`** stay word-form: a cross is an *edge* event, not a level, and has no symbolic operator. This is the one place words beat symbols. - **At-or-touch predicate**: `touches above` / `touches below` fire on *reaching or touching* the level (`>=`/`<=`) where `crosses` needs a *strict* transition (`>`/`<`). Same node (`cross`), a `touch` flag; omitted = strict. - **Re-arm band**: `crosses above hod band 5c` (or `touches …`). After the cross fires the series must clear back past `right ∓ band` and **re-arm** before it can fire again, so a lone anomalous print cannot phantom-fire (the xrearm-1 lesson: a bare cross tripped 10/12 phantom fires on one spurious tick). The band is a **positive** width (`c`/`%`/`bp`/bare); a zero/negative band can never re-arm and is refused (fail-closed). Omitted = a bare, single-shot cross. - **A cross may not be `held`** (fail-closed on all three surfaces — parser rejects the text, builder refuses to construct it, printer throws on it): sustaining an edge for a duration is exactly the phantom-fire anti-pattern. Reach for a re-arm `band` (reject the spurious tick) or hold a *level comparison* (`spot > hod held 30s`) instead. `within` over a cross stays legal — "the cross occurred inside this window" is a well-formed question. - **Break-and-hold is a postfix modifier**: `<condition> held <dur>`. **`within` likewise**: `<event> within <dur>`. Both are their own nodes (`break-hold`, `within`) so they can wrap any inner trigger; the printer parenthesizes a compound inner (AND/OR) but not an atom, so `spot < vwap held 60s` needs no parens. - **The thesis temporal envelope `until` / `at`** (kestrel-rtf, OSS-ADR-0049) are two more postfix siblings of `within`, each over a wall-clock `HH:MM`: `<event> until 15:45` (the expectation is in force up to a time) and `<event> at 15:45` (evaluated at a time). Own nodes (`until`, `at`) reusing the SAME trigger surface — the pre-registration envelope adds no second predicate language. Runtime firing of `until` / `at` is deferred: a session that cannot evaluate them reads UNKNOWN and de-arms with a logged reason (fail-closed). - **Nth-occurrence** prints an ordinal *word* for 1–12 (`first`…`twelfth`), numeric `<n>th` beyond: `second failed-break of hod`. Word form is chosen for readability; the fixed table keeps it deterministic. - **Structural events**: `<name> of <level>` (`failed-break of hod`) or bare `<name>`. Event names are open-vocabulary identifiers (kebab-case by convention). - **Phase events**: `phase <name>` (`phase open`). Phase names are open-vocabulary. - **Time windows**: `time <from>..<to>`, or one-sided `time after <t>` / `time before <t>`. At least one bound is required or the printer throws (fail-closed). - **Fill events**: `filled`, `partial-fill`, `unfilled`, `rejected`, `cancelled`; optional `<event> leg <n>`. A fixed enum (not open-vocabulary) — the fill lifecycle is engine-defined. - **Boolean precedence** (low→high): `OR` < `AND` < `NOT` < postfix(`held`/`within`) < atom. The printer emits **minimal parentheses**: `a > 1 OR c < 3 AND e > 5` (no parens), `(a > 1 OR b > 2) AND c < 3`, `NOT (a AND b)`. `AND`/`OR` are variadic and print flattened. - **`AND`/`OR`/`NOT` are uppercase**; series/operators are not. Keywords that introduce a clause or a boolean are uppercase throughout. ## Price expressions - **Anchors are bare keywords**: `fair intrinsic basis bid ask mid last join improve stub`. `fair` = ExecutionFair; `mid` is a legal authoring resting-price even though it is never a fair-value *source* (ARCHITECTURE §4) — the two roles do not collide in text. - **Offsets render tight, no spaces**: `fair-3c`, `mid+2c`, `fair-5%`. Matches the ADR's `fair-3c`. Units are `c` (cents) or `%`. - **`lean(a, b, x)`, `min(...)`, `max(...)`** render as calls with `, `-separated args. - **Absolute prices** are bare numbers (`4.20`); distinguished from anchors by being numeric. ## Order policy (resting-order execution) - **Price vs policy are separate**: `@ <price>` is *which* price; the trailing policy is *how* it rests. Policy clause order is fixed: `pricing` (`peg`|`fix`) → `esc` ladder → `cap` list → `floor` list → `cancel-if``gtc`. - **`esc <price> <dur>`** per rung; multiple rungs allowed, printed in array order. - **`cap <price>` / `floor <price>`** are lists, one keyword each (`cap fair cap 5.00`). - **`cancel-if <trigger>`** is a per-order guard; the plan-level `CANCEL-IF` is the same trigger at a wider scope. Both reuse the trigger algebra. ## Legs & tickets - **Leg**: `<side> <qty> <strike> <right>``buy 2 +1 C`. Sides `buy`/`sell`; rights `C`/`P`. Equity/underlying legs are ABSENT in v1 (options-only), not stubbed. - **Strikes**: relative `+1`/`-1` (signed, `+` shown for ≥0), absolute `450`, `atm`, or delta `25d`. `atm` is its own kind rather than `+0` to read cleanly. - **Multi-leg tickets** print legs `, `-separated. A valid v1 ticket is single-leg; multi-leg only appears under the reserved `atomic` flag. - **`atomic`** prints as a leading keyword on the ticket (`DO atomic buy 1 +1 C, sell 1 +2 C @ fair`). It round-trips through the AST (`atomic?: boolean`) but phase-3 parsing REJECTS it loudly (ADR-0005) — reserved, never faked. ## Plan header & clauses - **Header order (fixed)**: `PLAN <name> [budget] [ttl] [regime] [priority] [standing] [prov] [atomic]`, then indented `USING`, `WHEN`, then clauses in authored array order. - **Clause order is preserved from the array**, not normalized — clause sequence is author-meaningful (reads top-to-bottom as the plan's logic). - **`ttl`**: relative `+30m` or absolute clock `16:00` (hold-to-close is `ttl 16:00`). - **`regime {intraday: trend}`**: brace-map, `scope: value` pairs, `, `-separated, array order preserved. Open-vocabulary values (ADR-0005). - **`standing <s>`** prints the lifecycle enum (`authored`/`armed`/`versioned`/`superseded`). - **`prov {...}`** on a plan header; module-level provenance prints as a `PROVENANCE {...}` directive line. Brace fields in fixed order `tier, author, origin, replay`; `tier` is a bareword, the rest are quoted strings. Only present fields print. - **`TP`**: target is `+100%` (pct, sign shown), `2x` (mult), or a price; optional `frac <f>` (fraction of position — the documented TP-fraction control), optional held quantifier, optional `@ <price>` and policy. At runtime the target is the tier threshold, evaluated on `fair` (kestrel-lic3): the `@`-form arms its sell once fair crosses the threshold (resting at the `@` exec anchor); the no-`@` form rests at the target-derived price immediately and fills on market crossing. - **`RELOAD [WHEN <trig>] <legs> @ <price> <policy>`** — the on-adverse trigger is inline `WHEN`. - **`ARM [WHEN <trig>] [basis <price>] [<held-quant>]`** — the on-arm trigger plus the two inventory bindings (basis anchor, held-leg quantifier) from ADR-0005. Bare `ARM` = arm immediately. - **Held-leg quantifiers** print `foreach held leg` / `any held leg`, attached to `TP` / `EXIT` / `RELOAD` / `ARM` after the head. ## Wake - **Clause order (fixed)**: `WHEN`, `DELIVER`, `PRIORITY`, `COALESCE`, `BUDGET`, `UNIVERSE`. - **`DELIVER <view> [MANDATORY] [KEYFRAME]`** — delta frame is the default (unmarked); `KEYFRAME` opts into a full keyframe (CONTEXT: Wake delivery). - **`BUDGET`** prints present parts: `12 wakes/day`, `5000 tokens/day`, or both space-separated. A Wake with neither part omits the line. - **A Scan is a Wake with a `UNIVERSE <name>` clause** — no separate `SCAN` keyword (CONTEXT: Scan is the Wake *kind*, not a new statement). ## Citation (the `because` pre-registration clause, OSS-ADR-0049) - **`BECAUSE sha256:<64 hex>`** — an optional clause on **Plan and Wake only**, carrying the content hash of a platform-side Thesis (kestrel-rtf). It is an optional AST field (`because?: Citation`), NOT a fifth statement kind — the algebra stays View/Wake/Plan/Grade. - **Content hash only**: the digest reuses the repo's ubiquitous 64-lowercase-hex sha256 shape (as `bus_sha256` / `plans_sha256` / `ConfigId`); the thesis prose lives platform-side, never inline. Printing a malformed digest is refused symmetrically (the printer would otherwise emit text that cannot re-parse). - **Fixed placement**: in a **Plan** the `BECAUSE` line prints after `WHEN`, before the clauses; in a **Wake** it prints after `WHEN`, before `DELIVER`. At most one `because` per statement. Because it prints byte-stably like any canonical line, it binds into the armed-document hash `sha256(print(module))` with no special casing — pre-registration is tamper-evident once armed (round-trip byte-stable, golden contract). ## Grade - **Header**: `GRADE <what> <name> [OVER <from>..<to>] [FILL <model>]`. `<what>``plan wake view pod tag`. - **Corpus range**: `<from>..<to>` with opaque date tokens (`2025-01..2026-06`); the AST does not parse the dates in v1. - **`VS`** prints counterfactuals space-separated in a **fixed canonical order** (`ungated null bracket`), de-duplicated — `VS` is a set, so canonical order is stable regardless of authoring order. - **`BY`** prints dimensions `, `-separated in **authored array order** (dimensions are an ordered stratification): a series path, or the keywords `vehicle` / `name` / `lineage`. ## Pod / Book - **Recursive indentation**: a child Pod prints at parent-indent + 1; a Book prints its header inline and appends any `RISK` lines at +1. - **`RISK <metric> <threshold> -> <action>`**`RISK day-loss 2R -> halt`. Metric and action are open-vocabulary identifiers; threshold is a quantity. - **Book header (fixed order)**: `BOOK <name> [budget] [coverage <instr…> thesis "…"] [arbitration <policy> [concurrency <n>]]`. Coverage is instruments then thesis, matching ADR-0002's "instruments alone are not coverage" (thesis is mandatory when coverage is present). - **Arbitration** is an open-vocabulary policy word plus optional `concurrency <n>` — how a book's concurrent plans compete for its envelope. ## Module - **Imports**: `IMPORT { a, b } FROM "path"` — ESM-like, names in braces, path quoted (ADR-0003). - **Module header order**: imports, then `PROVENANCE`, then `USING`, then a blank line, then statements. - **`USING signal <instr> exec <instr> <expiry>`** — signal and/or exec, each optional. Expiry selectors: `<n>dte` (0dte/1dte), an absolute date token, or a named tag. ## USING elision (the round-trip subtlety, ADR-0004 / ARCHITECTURE §2) - **The AST is always fully qualified**; the printer elides what matches the ambient `USING`. A plan whose `USING` equals the module's prints **no** `USING` line; a plan that differs on only one side prints **only that side** (`USING exec QQQ 0dte`). Instruments compare by symbol + expiry. This is why `print(parse(text))` can be shorter than a fully-qualified AST would suggest, yet still round-trips: the parser re-applies the ambient defaults. ## View addressing (which surface can say which View a frame renders) How a rendered frame's pane set is *addressed* — distinct from the View **statement** (the authored artifact above) and from the Wake `DELIVER <view>` clause (the authored delivery binding). Addressing is the runtime question: given a frame to render, which View applies? - **One resolution point, three legs.** `resolveView(view, frameKind, seat)` (`src/frame/pane-catalog.ts`) owns the precedence: **explicit View > seat founder View > phase default**. An explicitly passed View always wins; a seat is consulted ONLY when no View was passed; with neither, the frame kind's DEFAULT panes render, byte-identical to before the catalog existed. Unknown pane id or over-budget View ⇒ refused loudly (fail-closed), never silently degraded. - **The in-process carrier is `RenderOptions {view?, seat?}`** (`src/frame/render.ts`): `view` is a `ViewSelection`, `seat` a `SeatId` from the closed pod roster (`src/frame/seat.ts``pm`/`strategist`/`watcher`; an unknown role is refused, never coerced). `renderBriefing`/`renderWakeDelta` stay pure functions of (input, options). - **The config axis is `AgentConfig.seatViews`** (`src/session/agent.ts`), a `SeatViewsPolicy` (`"founder" | "phase-default"`, ADR-0041 §2 / A1). `"founder"` ⇒ the live harness passes the acting tier's seat into the render (strategist at OPEN, watcher at wakes — `src/session/harness/live-agent.ts``renderBriefingPrompt`/`renderWakePrompt`, `src/session/harness/prompt.ts`), so a seat with a graded founder seed reads it when no View was authored. Absent or `"phase-default"` ⇒ NO seat is passed ⇒ byte-identical output. A **ConfigId axis** (folds into `deriveConfigId` automatically), never an envelope field — a founder-View run mints a distinct grid column. Guard fixture through the real driver: `tests/simulate.founder-seat-views.e2e.test.ts`. - **Who can construct the axis today: programmatic harness drivers only.** The only constructors of an `AgentConfig` are the bench/tournament scripts and tests. Every other surface is a stated **non-goal today**, fail-closed rather than half-wired: - **CLI** — the `frame`/`percept` verb accepts no `--view`/`--seat` flag (`src/cli/commands/frame.ts`); `LocalBackend.getPercept` calls the renderers with no `RenderOptions` (`src/cli/backend/local.ts`), so the CLI renders phase-default panes only. `view`/`seat` are `RenderOptions` fields, not `BriefingInput`/ `WakeDeltaInput` fields, so no input JSON can smuggle them in. - **Remote CLI** — `RemoteBackend.getPercept` REFUSES (`REMOTE_UNSUPPORTED`): the contract has no View route, and fabricating one is an owner decision (kestrel-5rb owner-note, `src/cli/backend/remote.ts`). - **Agent protocol** — no op or argument shape carries a View or seat (`src/sdk/op-schema.ts`); `session/frame` delivers the TYPED `SessionFrame` struct, so `resolveView` never runs on the wire path — rendering is a client concern. The AUTHORED leg still travels here as document text (a Wake's `DELIVER <view>` inside `advance`/`revise`); the role-keyed legs have no carrier. - **MCP** — no Frame-render tool exists; tool shapes are single-sourced from the same op schemas, which carry none (`src/mcp/server.ts`). - **SDK facade** — `openSession(subject, document?)` takes no `AgentConfig`; no SDK shape carries `view`/`seat` (`src/sdk/types.ts`). Importing `src/frame` directly and passing `RenderOptions` is the library, not the SDK contract. These absences are deliberate scope, not omissions: CLI exposure and any remote View projection are tracked separately (kestrel-wa0j.73.2 / kestrel-wa0j.73.3), and each new exposure ships with its own red-on-inert fixture through the real driver. **Decided (2026-07-17, kestrel-wa0j.73.3).** The client renders. The wire keeps the typed `SessionFrame` as canonical truth; there are no server-rendered Frame bytes on the wire, so recomputability rests on the struct, not on a rendering the server chose. `resolveView` is a client-side concern: the SDK ships `resolveView` semantics (role-keyed View addressing) when a real remote consumer materializes, not before — `openSession` gains no render-config argument and no remote Frame-render op is minted speculatively. This ratifies the remote leg's current refusal (`RemoteBackend` `REMOTE_UNSUPPORTED`) as the intended contract, not a gap awaiting a server-render fix. ## Deliberate ABSENCES (documented, not stubbed — Task rule) - **Equity/underlying legs** (options-only in v1). - **Template parameterization / plans-with-holes** (ADR-0003 defers; `USING` is the only customization point). - **Size-modulation and time-parameterized schedules** in plan text (ADR-0005 defers; conviction stays out of plan text by design). - **Multi-instrument N-node graph** beyond one signal + one exec pair (ARCHITECTURE §9; the types do not preclude it — `SeriesRef` paths and per-`USING` roles generalize — but no graph syntax ships). - **Baseline stats beyond `p`/`sigma`**, and **fill-event leg semantics beyond an index**. ## Parse-time fail-closed doctrine checks (golden-corpus phase) Four invariants are enforced at parse time and exercised by `tests/golden/rejects/*` — each fails closed with a `KestrelParseError` (line/col + the doctrine named), never a silent degrade: - **`atomic` multi-leg is refused, never faked** (ADR-0005) — reserved keyword → loud rejection, in both the plan header and any ticket. - **EXIT may not condition on a mark** (`mid`/`bid`/`ask`/`last`) — the "marks lie" doctrine (ADR-0005; ARCHITECTURE §6.3: the observed mid is a health signal, never a value). The guard inspects the EXIT **trigger** only; resting an order *at* a mark price stays legal authoring. (Registry-class refinement — knowing which *named* series is an option mark — still belongs to the later registry phase; this is the syntactic slice available today.) - **Provenance may only narrow** (ARCHITECTURE §6.6) — a statement inside a module whose declared `prov` tier out-ranks the module's `PROVENANCE` tier is elevation → refused (`unvetted < candidate < vetted`). The channel-authority slice of the ceiling is a harness concern; this is the intra-document slice. - **A risk budget must be positive** (ARCHITECTURE §6.1: `size × max_loss ≤ budget`) — a zero/negative `budget` can never admit a real position, so it is refused rather than armed as a dead plan. - **A `because` citation is content-hash-only, Plan/Wake-only** (kestrel-rtf, OSS-ADR-0049) — three symmetric refusals: (a) a digest not of the fixed shape `sha256:<64 lowercase hex>` (`because sha256:deadbeef`); (b) an inline thesis body — any token trailing the digest (the body lives platform-side); (c) `because` on a View or a Grade — a citation pre-registers an *executable expectation*, which only a Plan or Wake authors. ### Doctrine note: marks in triggers The mark ban is **EXIT-only, and deliberately so** (ADR-0005; ARCHITECTURE §6 invariant 3). The `findMarkInTrigger` guard fires only in the EXIT clause because that is the failure mode the doctrine names: EXIT keys a *position's fate* on a possibly-fictional book, so acting on a lying mark is where money is lost. Everywhere else a trigger may legitimately **observe** `mid`/`bid`/`ask`/`last` as *book-state signal* — spread-regime and dark-book detection read exactly these. The invariant governs **prices** (mid is never a silent price anchor, never worth), not book-state observation; it is not a blanket ban on the mark series. Widening the guard past EXIT would refuse real, legitimate authoring — the EXIT-only scope is the resolution, not an oversight. ### Doctrine note: `atomic` is refused symmetrically `atomic` is refused on **every** surface, not just the parser (ADR-0005, ADR-0004 round-trip): the parser rejects the keyword, the **builders refuse to construct** an atomic clause/plan, and the **printer throws** if it ever encounters `atomic: true`. The AST keeps the field so the parser has the vocabulary to reject loudly and the type documents the future surface — but because no surface will emit or construct it, `parse(print(x))` round-trips. Atomicity is reserved until a whole-structure preflight + atomic execution adapter exist; it is never faked. ### Doctrine note: the never-naked SELL boundary (runtime, ARCHITECTURE §6.1) Legs are independent single-leg children (atomic multi-leg is refused), so an independent short leg carries unbounded risk unless it merely closes a long we already hold. The runtime therefore admits an **entry / ALSO / RELOAD** leg with `side: sell` **only as a COVERED sell**: at fire time `uncovered_qty = sellQty − (net held of that exact leg − qty already committed to resting sells)`; `uncovered_qty > 0` ⇒ the leg is **refused loudly** (a `PLAN` event, reason `"uncovered sell refused: never naked"`) and never submitted, while **other legs in the same `DO` still proceed**. A covered sell **consumes $0 budget** (it reduces risk, not adds it). TP/EXIT sells are a separate path — they are already clamped to held inventory and intrinsic-floored, so they are exempt from this gate. ### Doctrine note: market-level vs premium-level triggers (fade-ladder fix) A trigger comparing an **underlier-space** series to a **premium-space** anchor is a category error: `RELOAD WHEN spot < basis` compares the underlier price (~200–300) to the position's option cost basis (~$2), so it is either always-true or always-false — never the intended "reload as the market falls further". The fade-ladder accept fixture originally carried exactly this bug; it now ladders on genuine market levels — `spot < or_low` (breach the opening-range low), then `spot < lod` (breach the day's low) — two distinct underlier-space levels the fade deepens through. The rule: a trigger relates **like-space** series (underlier vs underlier level, or an org-$ series vs an org-$ baseline); `basis`/`fair`/`intrinsic`/`mid` are **premium-space price anchors** for the `@` price line, not trigger operands against `spot`. ### Doctrine note: `because` pre-registration is content-hash-only, by construction The citation carries a digest and nothing else **because the digest is the whole point** (kestrel-rtf, OSS-ADR-0049). A Kestrel document's armed identity is `sha256(print(module))`; a byte-stable `BECAUSE sha256:<64 hex>` line binds into that identity with no special casing, so pre-registration is tamper-evident the moment the plan is armed. An inline thesis body would be un-hashable-into-identity noise and is refused; the prose lives platform-side, referenced by the hash. The clause attaches to a **Plan or Wake only** — the two kinds that author an *executable expectation* against which a thesis can later be graded — never to a View or Grade, and never as a fifth statement kind (the algebra stays View/Wake/Plan/Grade, OSS-ADR-0001). All three refusals fire symmetrically on parser, builder, and printer (OSS-ADR-0004 round-trip): a hand-built citation throws exactly where the parser does. ### Provider hooks: wired vs absent-with-reason (RUNTIME §2/§3) The session's canonical provider now serves two of the OPTIONAL `SeriesProvider` hooks and leaves one absent-with-reason: - **`timeOfDayMinutes` — WIRED** (ET wall-clock, `America/New_York`, off the injected event `ts`): enables `time HH:MM..HH:MM` windows and the absolute `ttl 16:00` deadline. Pure calendar arithmetic (tz database), no wall clock read — deterministic replay (`src/session/clock.ts`). - **`fillEvent` — WIRED, session-scoped** (`src/session/sim.ts` `FillTelemetry`): `filled` / `rejected` / `cancelled` latch to a definite `true` once seen on the emitted ORDER stream. It is deliberately **session-scoped and all-or-nothing** — per-plan / per-leg targeting, `partial-fill`, and `unfilled` are ABSENT (single-leg fills are never partial; `leg <n>` needs multi-leg tickets, which are reserved). Those read UNKNOWN. A **leg qualifier is refused by the ENGINE**, upstream of the provider (`ComposedProvider.fillEvent`, `src/engine/plans.ts`), and refused **loudly**: UNKNOWN *plus* a logged de-arm reason, because the session-scoped telemetry would otherwise answer `filled leg 1` with "some fill, anywhere, by anyone" — a plan armed on its own leg firing on a stranger's fill (cross-plan bleed, kestrel-s3h8). The scoping capability itself is kestrel-qbwo.2; when a provider can honour the qualifier it is admitted at that seam, not by dropping the refusal. Pinned e2e by `tests/session.fill-leg-qualifier-dearm-e2e.test.ts`. - **`structural` (detector events, `failed-break of hod`) — ABSENT** with reason: no detector reducers ship in the v1 session, so structural-event triggers read UNKNOWN and de-arm cleanly (fail-closed). They are a later milestone, not a stub. ### Doctrine note: CANCEL-IF is acquisition-only (deliberate scope) `CANCEL-IF` (plan- and line-level) cancels **unfilled acquisition children (entry/reload) only**; resting **TP/EXIT management orders persist**. This matches author intent — "stop trying to get in" — while inventory management stays governed by TP / EXIT / INVALIDATE. It is a deliberate scope, not an oversight (RUNTIME §5). ### Report note: input pinning + honest reprice tally (F6) The graded report pins **both** ends of the computation: `session.determinism_hash` is the sha256 of the emitted PLAN/WAKE/ORDER stream (the OUTPUT), and `session.bus_sha256` is the sha256 of the raw INPUT bus. `session.reprice_count` is the honest count of actual cancel/replace reprice submissions (peg drift + esc-stage boundary); `orders[].esc_stages` is the ladder **rung** an order occupied, not a count of reprices (the earlier `reprices` field conflated the two). ## Parser bug fixed this phase - **Zero-padded expiry dates** (`SPY 2026-07-17`) previously round-tripped lossily — `parseExpiry` rebuilt the date via `Number()`, dropping the leading zero (`2026-7-17`). Fixed to preserve the raw digit-token text (as `readCorpusToken` already does for grade corpus ranges). Locked by the property generator (which emits padded + unpadded dates).