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.

399 lines (343 loc) 32.1 kB
# The live gate is a BYO broker adapter — one venue-agnostic seam, three OSS reference venues **Status:** **Proposed — design for owner review (2026-07-14).** This ADR is DESIGN ONLY; it lands **no code**. The venue-agnostic seam it records is **already implemented** (bead `kestrel-7o2.4`: sim stays byte-identical, the paper loop is proven, live fails closed); the per-venue faces and — the foreground of this ADR — the **safety envelope** are not yet built, and none of them ship until the owner confirms the named open questions (below). It **Completes** the adapters charter (`docs/ARCHITECTURE.md` §4: "sim | paper | live = ONE path, different gate"; `adapters: broker (BYO/MCP)` · `market-data feed` — until now only the lake edge was built). It **Extends** ADR-0017 (the execution core is instrument-general — the `Gate`/`OrderIntent` seam a broker plugs into), ADR-0012 (the Simulator seam and the determinism line at the returned turn — the live gate moves only the gate's downstream side), ADR-0011 (the Blotter projector is a pure function of the Bus — the live gate must still write a replayable Bus), ADR-0016 (resting-episode identity and the *sampled* fill channel — live fills *replace* the sampled channel with observed ones), and ADR-0007 (pod class vs instance: `live` is a singleton per pod lineage — the live adapter must be singleton-guarded, and for live that guard must be **cross-process**). It **Reconciles** the protocol scopes (`src/protocol/index.ts`: `broker`/`live` are deliberately **excluded** from `WALLET_SIGNABLE_SCOPES` — live authority still requires a human signature) with the OSS goal of a standalone-usable package. **Nothing here weakens a single fail-closed or bounded-risk non-negotiable; it composes the existing `Gate` seam into a real-money edge and re-expresses the determinism invariant as record-honesty where byte-replay is physically impossible.** ## Context The charter's thesis is that **sim, paper, and live are one code path — only the gate differs** (`docs/ARCHITECTURE.md` §4; `CONTEXT.md` "Mode"). That thesis has been true in the code up to *one construction line* for some time. The execution seam is a two-method interface, `Gate` (`src/engine/plans.ts`, re-exported through `src/engine/index.ts`): `submit(intent)` rests an order and returns a ref to correlate fills against; `cancel(ref)` pulls one. The doc comment on that interface *already names the design*: "in `sim` the gate wraps the fill engine; in `live` it wraps the broker adapter — one engine path, different gate." The engine hands the gate a fully-resolved `OrderIntent` — price already resolved to a numeric `px`, bounded-risk already enforced, SELL already floored at intrinsic, carrying a receipt — and the engine **never emits ORDER events itself**; `place`/`fill`/`cancel`/`reject` come *back from the gate/fill layer*. The one prior implementation, `SimGate` (`src/session/sim.ts`), wraps the pure fill engine; the mode-aware record layer is already built around it — `fidelityOf(mode)` returns `live ? "realized" : "modeled"`, `instanceIdentityOf` stamps mode into instance identity, and the protocol layer already reserves `broker`/`live` scopes that require a human wallet signature. **Two of the charter's three edges were charter-only until now: the broker and the feed.** There was no broker adapter and no `Feed` interface anywhere in `src/`; in sim the "feed" *is* the input `BusEvent[]` tape folded one event at a time. The charter names both edges but historically only the lake was implemented (`src/adapters/lake/`). **That gap is now closed at the seam.** Bead `kestrel-7o2.4` (under the `kestrel-7o2` epic *"LiveGate & broker adapters"*, whose seam is defined by `kestrel-7o2.1`) landed the **venue-agnostic** seam in `src/adapters/broker.ts`: a `BrokerAdapter` that *is* a `Gate`, a `FeedSource` that yields the *existing* `BusEvent` union, and a **mode-keyed `makeGate` factory** that dispatches sim | paper | live. It is proven along the three axes that matter: **sim is byte-identical** (the seam changes nothing on the sim path), the **paper loop runs** (the paper gate adapter, `kestrel-eaa.1`: a live feed in, `FillModel`-simulated fills out), and **live fails closed** (there is no live transport yet, so a live request de-arms rather than routing). What remains is *per-venue faces* and the *safety envelope* — the subject of this ADR. **Why now, and for whom.** This work is **not on the managed platform's critical path** — the managed service can run on recorded tapes and the existing sim/paper grading for a long time. It is, however, **required for the OSS package (`kestrel.markets`) to be usable standalone**: an owner-operator who installs the package, points it at *their own* brokerage account, and runs *their own* authored plans has no live edge to run against without it. The value is **dual-use**: the exact same seam that makes the OSS package standalone-usable is the seam the managed runtime wraps as a service. Shipping reference bindings is what turns "sim | paper | live is one path" from a charter promise into a property an outside user can exercise. ## Decision **A broker adapter is a `Gate` with an inbound fill/reconcile pump; a feed adapter is a `BusEvent` producer; one mode-keyed factory picks between them; three OSS reference venues (IBKR, Alpaca, Robinhood) implement the one seam; paper is the KESTREL-ENFORCED default and live real-money routing is impossible without an explicit Kestrel-level arm behind a safety envelope that lands BEFORE any live-capable order code; custody never touches Kestrel; and the same adapter is usable in-process or wrapped as a managed sidecar.** ### 1. The seam is venue-agnostic and already built (`kestrel-7o2.4`) The seam lives in `src/adapters/broker.ts` and follows the `src/adapters/lake/` edge pattern (interface + factory + env-driven config + typed-loud-error). Three parts: - **`BrokerAdapter extends Gate`.** It satisfies the existing `Gate` seam directly — no new execution interface at the engine boundary. `submit(intent)` transmits a Gate-cleared, bounded, floored order and returns a broker-correlatable ref without blocking on the ack; `cancel(ref)` pulls a resting order. But `Gate` alone is *synchronous and pull-shaped* by design (that is what keeps sim deterministic), and a real broker submit is a network round-trip whose ack/reject arrives later and whose **fills arrive out-of-band, driven by the broker, not by `step()`**. So the adapter carries a **second, inbound face** — a *fill/reconcile pump* that injects broker-originated ORDER events (`ack`/`fill`/`partial`/`reject`/`cancel`) onto the same append-only Bus the engine already learns fills from, each pinned to the broker's own event time as `asOfSeq`. The engine learns of a real fill exactly as it learns of a sampled one. Fail-closed is mandatory on **both** faces, exactly as the lake throws rather than returns empty: a rejected order, an ambiguous response, a socket/stream drop mid-submit, or an un-acked order is a **loud typed error → STAND_DOWN + logged reason**, never a retry-into-double-fill and never a silent assume-filled. - **`FeedSource` — a `BusEvent` producer.** It yields the **existing `BusEvent` union** (`src/bus/types.ts`) — `META` header, then `TICK/BOOK` (option chain legs + underlier) and `TICK/SPOT` — so the per-event fold in `SessionCore.step` is *untouched*. A live source pumps these in place of the finite tape. It must deliver a **two-sided option-chain snapshot**, not just a spot tick, because `@fair` (ExecutionFair) resolves against the book legs; a one-sided/dark book → fair-null → fail closed. - **`makeGate` — the one mode-keyed factory.** The single line where sim | paper | live dispatches. This replaces the historical hardwired `new SimGate(...)`. Keyed on `Mode` (`src/bus/types.ts`, carried on `MetaEvent.mode`), it returns the sim gate, a paper gate (live feed + simulated fills), or a live gate. The record/identity/fidelity layers were already mode-aware, so past this factory the charter's "one path, different gate" is **literally** true. ### 2. Three OSS reference venues, one seam — BYO and non-custodial The seam is venue-agnostic; the OSS package ships **three reference venues** so live is standalone- usable across the audiences the package serves. All three are **non-custodial BYO-account**: the client runs against *their own* account with *their own* credentials, and Kestrel transmits only pre-authorized orders. The *faces* differ per venue; the *seam* does not. | Venue | Transport (per-venue face) | Audience | Beads | |---|---|---|---| | **IBKR** (IB Gateway / TWS API) | one local **TCP socket** serving quotes, chains, *and* order routing — feed and broker are two faces of one session | owner's internal prop use — **first** | `kestrel-7o2.5/.6/.7/.8` | | **Alpaca** | **OAuth + REST** (+ streaming) — no socket, simpler | the public-platform + managed-service broker (equities + equity options, per ADR-0017) | `kestrel-7o2.13` | | **Robinhood** | **OAuth + REST** via the connected Robinhood MCP — simpler | retail owner-operator | `kestrel-7o2.14` | IBKR is confirmed first (the owner uses it for internal prop). Alpaca is the venue the public platform and the managed service already target (ADR-0017 records the platform's universe as equities + equity options on Alpaca-tradeable names). Robinhood extends the same non-custodial BYO shape to a retail account over a REST/MCP face. Each per-venue binding fills two gaps the sim seam elides: **contract resolution** (Kestrel-side identity in — `symbol/right/strike/multiplier` — venue-side contract out; a *transmitter*, never a re-pricer) and **auth/credentials** (a socket `clientId` for IBKR; an OAuth token for Alpaca/Robinhood — Open q1). Binding code is pure TypeScript on Bun; no second language. ### 3. Paper-first is KESTREL-ENFORCED — the factory is the primary gate **This is the first foreground safety decision, and it corrects a tempting error.** Paper-vs-live is **not** merely a broker-port or account fact. It is a **Kestrel-enforced mode gate** at the `makeGate` factory, with the broker's own paper/live account as a *secondary* layer, never the primary one. - **`paper` is the default.** `makeGate` returns a paper gate unless it is explicitly handed live authority. A run with no live arm cannot reach a live venue — the factory has nothing to build a live gate from. - **A live gate requires an explicit Kestrel-level arm.** Live is not a flag flip on a sim run; it is **human-gated promotion** (ADR-0007; ARCH §8 milestone 7). The `broker`/`live` scopes are **excluded** from `WALLET_SIGNABLE_SCOPES` (`src/protocol/index.ts`) — live-activation authority requires a **human wallet signature**, and that signed authority must be threaded *into the `makeGate` factory* as the thing that unlocks the live branch. No signature → no live gate, structurally. - **The broker's paper/live account is defense-in-depth, not the gate.** IBKR issues a paper account alongside the live one; Alpaca and Robinhood have paper/sandbox and live endpoints. Reaching the right one is a useful *second* barrier — but it is **never the primary control**. The invariant: **a misconfigured port or a fat-fingered endpoint must NOT reach live if Kestrel is not armed.** The Kestrel arm is the load-bearing gate; the broker account is the backstop. (Contrast: letting "which account you logged into" be the only thing standing between paper and real money would make a config typo a live-trading event. Refused.) The keystone in one sentence: **the factory admits live only on a human-signed Kestrel arm; the broker's own paper/live account merely agrees with it.** ### 4. The safety envelope lands BEFORE any live-capable order code (`kestrel-7o2.9`) **This is the second foreground safety decision, and it is a build-order invariant.** The **safety envelope** — bead `kestrel-7o2.9` — is: 1. **the explicit arm** (§3: the human-signed Kestrel-level unlock wired into `makeGate`); 2. **an L0 pre-transmit risk clamp** the adapter cannot bypass — **max order size, max position, max notional**, all fail-closed, applied *above* the adapter before any order reaches the wire; 3. **a kill-switch** — a single operator action (and any adapter-detected degradation: dead feed, lost heartbeat, reconciliation break) **de-arms the live pod and refuses new submits**, fail-closed, with a logged reason. STAND_DOWN is always reachable; 4. **a reconciliation trip** — a broker fill the engine did not originate, or an engine order with no broker terminal state, **trips the kill-switch**, never a silent divergence. The envelope is **mock-testable in full without a live venue** (an in-memory broker double exercises arm, clamp, kill-switch, and reconciliation-trip). The build order is therefore: > **seam (`kestrel-7o2.4`, done) → safety envelope (`kestrel-7o2.9`, mock-testable, next) → per-venue > transport / feed / paper (`kestrel-7o2.5–.7`, `.13`, `.14`; paper via `kestrel-eaa.1`) → live-capable > `placeOrder` (`kestrel-7o2.8`/`.10`) ONLY behind the envelope + owner sign-off.** No live-capable order code lands before `kestrel-7o2.9`. This is not a preference; it is the property that makes the worst outcome of a misconfigured run *refuse to trade*, never *trade unboundedly*. ### 5. Non-custodial BYO — the advisory line **Kestrel never holds custody.** The client runs *their own* broker session (IB Gateway they launch and authenticate, or their own OAuth grant to Alpaca/Robinhood) against *their own* account, authoring *their own* plans. Kestrel connects, transmits **pre-authorized** orders (Gate-cleared, L0-bounded, intrinsic-floored), and records every ack/fill/reject onto the Bus. Kestrel holds no custody and no standing credential beyond the session the client granted. **The advisory line (ARCH §9), stated load-bearingly:** BYO-broker dodges *custody*, not the investment-adviser line — auto-arming *someone else's* book is discretionary management. So **live stays BYO-broker AND BYO-plan**: the client authors their own plans and runs them against their own account; Kestrel transmits pre-authored orders and exercises no discretion. This framing is what lets the reference bindings ship in OSS. ### 6. Service-wrappable — one seam, in-process or sidecar The `BrokerAdapter`/`FeedSource` interfaces are transport-agnostic, so the *same* seam serves both consumers: - **In-process (OSS CLI):** the venue binding is constructed inside the owner-operator's own process and injected at `makeGate` — the standalone path. - **Sidecar service (managed runtime):** a thin wrapper is an **alternate `BrokerAdapter`/`FeedSource` implementation** that speaks to a managed broker-gateway process over a local RPC, exposing the identical two faces. The managed runtime consumes that wrapper exactly as the CLI consumes the in-process adapter — Alpaca being the managed service's own venue. The managed deployment (colocation, credential vaulting, orchestration) is the **platform's private concern**; the OSS adapter needs only a host/endpoint + credentials. Because the seam is identical, "honest grading is structural, not a promise" (ARCH §4) holds whether the adapter runs in-process or behind the sidecar. ### 7. The non-negotiables carry into live, unchanged Live inherits *everything*; the adapter may change only the gate's downstream side. | Non-negotiable | How it maps to a live order | |---|---| | **Fail closed: parse escape → STAND_DOWN** | The adapter's *own* surface is fail-closed: a rejected/ambiguous response, a socket/stream drop, an un-acked order → **STAND_DOWN + logged reason**, never retry-into-double-fill, never silent assume-filled. | | **Fail closed: unknown/stale series → de-arm** | A stale/dead market-data line drives canonical state to **degraded** (taints dependents, de-arms affected plans). A frozen re-printed quote must not advance `asOfSeq`. Live must not fire off a dead feed — *but this depends on an unbuilt feed-staleness/watermark marker* (see Risks + Open q4). | | **Never naked / bounded risk** | The order passes the engine's L0 envelope *before* the wire, and the §4 **L0 pre-transmit clamp** (max size/position/notional) on top. Uncovered short = naked → **refused loudly, never submitted.** The broker's margin check is *not* the risk boundary; L0 is *above* the adapter. | | **SELL floored at intrinsic** | The transmitted limit is the **already-resolved, floored** price. The adapter is a *transmitter*, never a re-pricer — it must not round, "improve," or re-derive the price. | | **Mid is never a price anchor; fair carries receipts** (ADR-0017) | `@fair` resolves off the venue's two-sided quote **with a receipt**; a one-sided/dark book → fair-null → fail closed. The venue's mid is a thin-book fingerprint, never the transmitted price. | | **Live is a singleton per pod lineage** (ADR-0007) | One live Session per pod lineage per account; reconnect/failover **re-attaches**, never spawns a doubling second. For live this guard must be **cross-process** (a control-plane lease, not an in-process singleton — see Risks + Open q2). | ### 8. Determinism, re-expressed for live: record honesty Sim's core invariant — *same input bus ⇒ byte-identical output* (`determinism_hash`, `SimRunId = sha256(graded Bus)`, `certify` re-projection, no wall clock, `gate.now` pinned to `ev.ts`) — **cannot hold live.** A live run has no replayable input tape; fills are non-deterministic external truth; a live gate owns its own clock. **Byte-identity of the fill layer is physically impossible live, and we do not pretend otherwise.** The invariant that replaces it is **record-honesty at the record layer**: the live gate must write *every* order, fill, reject, ack, and cancel back onto the **one append-only Bus** (each pinned to the broker's own event time as `asOfSeq`) so that **the Blotter projector (ADR-0011) still regenerates byte-identically *from the recorded Bus***. Determinism is preserved as *a pure function of the recorded live Bus*, not as *a pure function of a replayable input tape*. Fidelity flips `modeled → realized` (`fidelityOf(live)`, already named in code), carried with per-fill claims. The engine still injects `now` from Bus events; only the **host wait loop** (the feed pump) may read the wall clock — the "no wall clock on the runtime path" boundary moves to that host loop and no further. **Realized-fidelity replaces byte-identity as the honesty guarantee — it does not drop it.** ## Considered and rejected - **A broker MCP *as the seam boundary* (in place of `BrokerAdapter`).** Rejected: it would lose the typed `Gate` seam, the L0 admission boundary, and the fail-closed typed-error surface, and hide the inbound fill pump. An MCP is a *fine transport behind* a `BrokerAdapter` — which is exactly how the Robinhood venue is wired — but it must sit *behind* the typed seam, never replace it. - **Custodial / managed keys.** Rejected, non-negotiable: violates "Kestrel never holds custody" and crosses the advisory line. Client keys, client account, client-run session — always. - **A parallel live runtime / second code path.** Rejected: the whole value is that honest grading is *structural* because live is the same Session path with a different gate. A second path forks the invariant and makes grading a promise again. - **Make `Gate.submit` async (add a `Promise`/callback to the seam).** Rejected: it would break sim determinism and the synchronous engine path. Async is handled by the *separate inbound pump* (§1), not by reshaping the seam every sim run also pays for. - **A new `Feed` abstraction that reshapes `step()`.** Rejected: `FeedSource` emits the *existing* `BusEvent` union so the per-event fold is untouched. - **Paper-vs-live as only a broker account/port fact.** Rejected (§3): a config typo would then be a live-trading event. Kestrel's factory arm is the primary gate; the broker account is defense-in-depth. - **Let the broker's margin check be the risk boundary.** Rejected: L0 bounded-risk is *above* the adapter and is never bypassed (§4, §7). ## Consequences + Risks - **The charter's "one path, different gate" is literally true past the `makeGate` factory** — the seam is landed (`kestrel-7o2.4`), sim stays byte-identical, paper runs (`kestrel-eaa.1`), live fails closed. - **The OSS package becomes standalone-usable across three venues.** An owner-operator can run their authored plans against their own IBKR, Alpaca, or Robinhood account, non-custodially. - **Dual-use is preserved by construction.** The same seam the CLI injects in-process is the sidecar the managed runtime consumes (§6) — one seam, two deployments. - **Determinism is preserved where it can be and honestly replaced where it can't** (§8): the Blotter projector regenerates byte-identically from the recorded live Bus; fidelity flips to `realized`; live *adds* record-honesty legs rather than dropping the guarantee. - **Real money is the standing risk.** Every fail-closed and bounded-risk non-negotiable carries into live unchanged (§7), and the §4 envelope means the worst a misconfigured run can do is *refuse to trade*. This is a mitigation, not an absence of risk — live moves real capital. - **Reconciliation must anchor to the broker's AUTHORITATIVE PULL, not only push.** Callbacks/streams drop, dedup, and reorder; the source of truth is a **positions/orders query** against the broker, reconciled against the engine's Bus. A break trips the kill-switch (§4). Push events are an optimization over the authoritative pull, never a substitute. - **No retry-into-double-fill.** Every submit carries an **idempotent client order id**; on reconnect the adapter **resyncs** against the broker's order/position state before it may submit again. A reconnect never blindly re-transmits an in-flight order. - **The ADR-0007 singleton must be CROSS-PROCESS for live.** An in-process singleton does not prevent a second process (a stray CLI, a restart-before-drain) from double-submitting to the same live account. Live requires a cross-process guard — a control-plane lease per pod lineage per account (`kestrel-7o2.2`) — not merely an in-process instance check. - **Live settlement semantics differ from the sim backtest artifact.** Sim cash-settles held inventory at intrinsic at final spot — a backtest convenience. Live positions are real and carried at the broker; end-of-session state is a mark-to-broker, not a synthetic settle (Open q3). - **Live fail-closed-on-stale depends on an unbuilt marker.** The "dead feed → de-arm" guarantee (§7) needs a **feed-staleness / watermark** signal that does not exist yet; `FeedSource.watermark()` names the shape, but the canonical-state taint path is an open dependency (Open q4). - **New surfaces to build (all later, none in this ADR):** the safety envelope (`kestrel-7o2.9`); the per-venue transports/feeds (`kestrel-7o2.5–.8`, `.10`, `.13`, `.14`); the realized-fidelity grading path + reconciliation legs; the contract-resolution and auth channels; the cross-process lease (`kestrel-7o2.2`); the sidecar wrapper. All additive; **no determinism-core change to the sim path.** ## Open questions (need owner input before any implementation) 1. **Per-venue auth / credential channels.** The concrete shape carrying IBKR `host:port:clientId` vs. an Alpaca/Robinhood OAuth grant into the live gate, and how the signed live arm binds to each without leaking a standing credential into Kestrel. 2. **The cross-process singleton mechanism.** How the ADR-0007 live singleton is enforced *across processes* (the control-plane lease, `kestrel-7o2.2`) — lease acquisition, renewal, fencing on a crashed holder. 3. **Live settlement.** How end-of-session state reads live positions (mark-to-broker vs. carry-forward), replacing the sim's intrinsic-at-final-spot settle artifact. 4. **The feed-staleness marker.** The watermark/heartbeat signal that drives a dead or frozen feed to `degraded` and de-arms dependents — the dependency the §7 fail-closed-on-stale row rests on. 5. **Venue rollout order.** IBKR first is confirmed (owner's internal prop use). Confirm the ordering of Alpaca (`kestrel-7o2.13`) and Robinhood (`kestrel-7o2.14`) behind it, and whether paper (`kestrel-eaa.1`) is required to be green per-venue before that venue's live face is attempted. 6. **Which fill truth grades a PAPER Blotter.** A broker's own paper engine produces paper fills — a distinct fidelity from Kestrel's `strict-cross` / maker-fair sim models. Does a paper Blotter grade on the broker's paper fills, or does Kestrel re-grade the paper tape through its own fill model? 7. **Order-lifecycle richness vs. the thin `Gate`.** Broker **partial fills**, multi-leg combos filling leg-by-leg, and post-only/IOC/GTC semantics exceed the session-scoped booleans the sim fill telemetry models. How much does the inbound pump surface to the engine vs. reconcile-and-summarize, and does `Gate` need `modify`/`replace` or does peg/esc stay cancel+resubmit? 8. **The advisory line.** Owner/legal sign-off on the BYO-broker AND BYO-plan posture (§5) as the shippable-in-OSS stance, and whether any additional guardrail (an explicit "you author your own plans" attestation) is required. ## OSS / private boundary (what ships where) **Ships in OSS (`kestrel.markets`):** the venue-agnostic `Gate`/`BrokerAdapter`/`FeedSource` seam and the `makeGate` factory; the three reference bindings (IBKR, Alpaca, Robinhood) as BYO and non-custodial (client account, client keys, client-hosted or client-granted session); illustrative fixtures on **generic tickers only** (SPX/SPY/QQQ), chosen to teach the seam, never a real book/coverage/thesis (ARCH §7; `AGENTS.md`). **Stays private:** the **prop strategy itself** — its books, coverages, and theses — and the fact that it routes through any particular venue; the **managed deployment and orchestration** (the sidecar, credential vaulting, colocation — the platform's concern, ARCH §5); and the **data-redistribution posture** — market data is customer-BYO only; the OSS feed adapter pulls the *customer's own* data into the *customer's own* runtime and never turns the managed service into a data reseller. One line: **OSS ships the seam + three reference bindings that run BYO and non-custodial; private keeps the managed orchestration and the strategy that happens to route through a venue.** ## Amendment (2026-07-17): spike-grade answer to open question q3 (live settlement) — the expiry carve-out in `reconcile()` Open question q3 ("Live settlement: how end-of-session state reads live positions, replacing the sim's intrinsic-at-final-spot settle artifact") gets a **spike-grade** answer here — enough to run the supervised hold-to-cash-settle IBKR spike (`kestrel-7o2`), not the final word on carry-forward semantics. Recorded as an amendment per repo convention (ADR-0005 precedent); the owner still owns the broader mark-to-broker-vs-carry-forward decision. **The problem the spike forced.** `reconcile()` is a fail-closed audit: it compares the engine/Blotter EXPECTED signed net position against the broker's AUTHORITATIVE pull, and any divergence beyond `tolerance` trips the kill-switch (§4). But a **cash-settled index option held to settle** — the *entire* point of the spike — DISAPPEARS from the venue's position report at expiry and is replaced by cash. Read naively, that is expected-N-vs-broker-0, i.e. a break, so the switch would trip on the **normal end of every position** — the happy path of the whole design. **The answer: settlement is an EVENT, not a discrepancy — recorded, never silently absolved.** A disappearance is filed as a `SettlementRecord` on a dedicated settlement ledger (`broker.settlements()`) instead of tripping, **only** when every one of the following NECESSARY conditions holds (implemented in `IbkrPaperBroker.#settlementAbsolving`, `kestrel-7o2.24`): 1. **An oracle was supplied.** Absent `deps.settlementOf`, nothing is ever absolved — the carve-out is structurally unreachable, and every existing caller (which passes none) keeps the exact pre-carve-out behaviour. This is the fail-closed default. 2. **The position is GONE, not reduced** — a partial disappearance is a break (an expiry takes all of it). 3. **It was a LONG** — the spike is BUY-only; a vanished short is assignment/exercise territory whose cash flows this receipt does not model, so it trips. 4. **The leg is an OPTION with a KNOWN expiry**, read from the venue's own pre-resolved contract definition (WALL 2's book, via this session's ledger) — never inferred from a symbol or a calendar. 5. **The session actually REACHED the leg's own expiry**, by the tape clock the driver pinned (RUNTIME §0 — `broker.now`, never a wall clock). A vanish before expiry is a break. 6. **The venue posted its OWN settlement receipt** for the key. The date is necessary, never sufficient: a position that merely went missing on expiry day is not a settled position. 7. **The receipt accounts for the ENTIRE expected quantity, exactly.** 8. **The cash is real and non-negative** (`0` is legitimate — expired worthless, the common 0DTE end; a long can only ever RECEIVE at settlement, so negative/non-finite is refused). 9. **The settlement does not PREDATE the expiry it claims to settle.** The venue's receipt is the only near-sufficient condition and it still does not stand alone. **The narrowness IS the safety property**: a position vanishing for any other reason — an unexplained broker adjustment, a leg closed by someone else on the account, a partial — still trips, which is exactly what the kill-switch exists for. Proven by one POSITIVE control (expired-and-settled does not trip; a `SettlementRecord` is filed) and six NEGATIVE controls (pre-expiry vanish, no receipt, no oracle, predating receipt, partial, wrong-quantity receipt), all driven end-to-end through the real `runPaperSession` closing `reconcile()` — deleting the carve-out call site reddens the positive control; widening it reddens the negatives (`tests/session.paper.test.ts` describe block (5)). **Deliberately NOT a Bus event.** A settlement is recorded on a side ledger, not promoted to a first-class `OrderAction` — the ORDER vocabulary is CLOSED (`place | fill | cancel | reject`), exactly as a `commissionReport` folds onto the ledger only. Promoting settlement to a Bus event would change `serialize(project(bus))` bytes repo-wide and needs its own ADR + golden refresh. **Known spike-grade narrownesses (stated, not smuggled), left to `kestrel-7o2.24` / the full q3 ruling:** - **Expiry instant is 00:00 UTC on the `YYYYMMDD` date**, the *start* of expiry day — the LOOSER of the available bounds (it admits a disappearance at 09:00 on expiry day). Safe only because it is a necessary condition beside the sufficient venue receipt. The tighter bound (the leg's real last-trade/settlement instant) needs a settlement-time field the contract layer does not carry yet. - **Only positions this session ORIGINATED can settle** — the key→contract lookup reads this session's ledger, so a resumed session's seeded book or an account-pushed leg has no definition and TRIPS rather than records. That errs toward the halt (the right direction); closing it needs a key→contract lookup the `ContractBook` does not expose today. - **BUY-only.** Short-side settlement (assignment/exercise) is explicitly out of scope and trips. This answers q3 for the spike; the broader carry-forward-vs-mark-to-broker end-of-session semantics for a multi-session live account remain the owner's call.