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
Markdown
# 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.