# OSS-ADR-0050 — A leg carries its own expiry; `exp` marks it and the tenor inherits

Status: accepted (2026-07-16, owner ratified the `exp` coinage)

> **Implementation status (2026-07-18):** Accepted; **decisions 1–7 LANDED** in `32e67a5`
> (*per-leg expiry authoring + widen `#execFor` to a (symbol, expiry) key*, kestrel-ih5h
> seam 1), with the end-to-end fixture in `714118c`. **Decision 8 (re-keying the book)
> remains DEFERRED** — see below. Supersedes the 2026-07-17 audit note, which read
> "not yet implemented" and is now false in every particular.
>
> Landed in `32e67a5`:
>
> - **`exp` leg-expiry marker** — `parseLeg` (`src/lang/parse.ts`) reads `exp` via
>   `c.optWord("exp")` and reuses `parseExpiry`/`ExpirySelector` verbatim (decisions 1–2).
> - **Bare `exp` fails closed** — a following punct token, or end of input, is a parse
>   escape naming every legal form, never a silent inherit (decision 4).
> - **Builder parameter** — `buy(qty, strike, right, expiry?)` / `sell(...)` in
>   `src/lang/builders.ts`, the object peer of the text tail (decision 6, OSS-ADR-0004).
>   Purely additive: the `exp` tail prints only when `expiry !== undefined`, so
>   pre-existing plans print byte-identical bytes and no golden fixture changed.
> - **`#execFor` widened** — it now returns the `ExecRef` `(symbol, expiry)` pair rather
>   than a bare string, so `using.exec.expiry` is no longer dropped (decision 7).
>   `#execForLeg` lets a leg's own `exp` override the ambient tenor, and `#bookFor` — the
>   single place a book is fetched — refuses a book whose tenor CONTRADICTS the authored
>   one. A refused book quotes dark, so the line is unresolvable and the plan stays armed
>   and retries: fail-closed, never a fill against the wrong tenor.
>
> Fixture (`714118c`): `tests/bench.theta-cell-per-leg-expiry-e2e.test.ts` — a QQQ 0DTE
> ATM straddle on a WHIPSAW tape whose ambient `USING exec QQQ 2026-08-21` names a tenor
> the book does not carry, every leg authoring `exp 2026-07-17`, driven through the real
> `runSimulateSession`.
>
> **Still deferred — decision 8 only.** `foldBook` / `BookState` (`src/bus/types.ts`) and
> the sim's `#books` are unchanged: `src/session/sim.ts` still holds
> `#books = new Map<string, BookState>()` and folds with
> `this.#books.set(ev.instrument, foldBook(ev, this.#books.get(ev.instrument)))` — one
> expiry per instrument, keyed by symbol alone. `BookState.expiry` is consumed read-only.
> The cross-expiry contamination named in the Context therefore **remains latent**, and
> `mark-to-model.ts`'s expiry assertion remains the loud tripwire. Sequenced after
> `kestrel-wcnd` for the reason decision 8 gives.

## Context

`OptionLeg.expiry?: ExpirySelector` already exists in `src/lang/ast.ts` — landed
gated-off — but **nothing could author it**. `ast.ts` says so outright: the plan grammar
has no per-leg expiry syntax, so *every parsed or built leg leaves this `undefined`*. The
field was a seam with no surface.

The selector *language* already exists too. `parseExpiry` (`src/lang/parse.ts`) reads the
three `ExpirySelector` forms — relative (`0dte`), a date (`2026-07-17`), a tag (`weekly`) —
but only via `parseInstrument`, i.e. only for `USING exec SPY 0dte`. So Kestrel could name
an expiry for an *instrument* and not for a *leg*.

Two resolution sites are consequently expiry-blind:

- `#execFor` (`src/engine/plans.ts`) returns a bare `string` — `pr.plan.using?.exec?.symbol
  ?? this.#execDefault ?? ""` — and **drops `using.exec.expiry` entirely**. A plan whose
  exec tenor contradicts the book's tenor fills anyway, against a book it should never have
  seen.
- `foldBook` / `BookState` (`src/bus/types.ts`) carry **one expiry per instrument** as a
  latest-wins scalar, while legs merge on a `strike:right` key with **no expiry in the key**;
  `src/session/sim.ts` keys `#books` by instrument symbol alone. A second-expiry BOOK event
  therefore overwrites the scalar while merging its legs into the same map — silent
  cross-expiry contamination. `mark-to-model.ts` currently *asserts* held-leg expiry matches
  the book's single expiry as a deliberate tripwire.

`CONTEXT.md` carried **zero** expiry vocabulary (`grep -c -i expir` → 0), so there was no
glossary term to reuse and, per AGENTS.md, a coinage had to be proposed rather than assumed.

The forcing function is the options track: `kestrel-ih5h` (the fomc-straddle WHIPSAW theta
cell) and `kestrel-qga2` (the options-native cube, the headline RL curriculum) need a leg to
name its own tenor.

## Decision

1. **A leg may carry its own expiry, marked by `exp`.**
   `buy 1 atm C exp 2026-07-17`, `buy 1 +2 C exp weekly`, `buy 1 +1 P exp 1dte`. Two tenors
   may coexist on one ticket.

2. **One expiry language, not two.** The selector after `exp` is the existing
   `ExpirySelector`, parsed by the **same** `parseExpiry` and printed by the **same**
   `expiryStr` that `USING exec SPY 0dte` already uses. No new selector syntax is coined —
   only the marker.

3. **Absent expiry means INHERIT the ambient `USING exec` tenor.** Inheritance is the
   default and resolves at execution. A missing expiry is **never** a silent `0dte`: the
   ambient tenor is a real, authored value, and inheriting it is a decision, not a default.

4. **A bare `exp` with no selector is a parse escape → STAND_DOWN**, carrying a
   machine-readable reason that names every legal form. Fail closed; never a silent inherit.

5. **The marker is required rather than positional.** Mirroring the positional instrument
   form (`USING exec SPY 0dte`) is unsafe on a leg: a leg is followed by `,` or `@`, and
   `parseExpiry`'s tag branch calls `readName`, so a bare positional tag would silently
   swallow the following token. The instrument form survives positionally only because a
   `USING_STOP` stop-set fences it. A marker is the minimum that keeps the form unambiguous.
   `exp` is unused elsewhere in the grammar, is the natural abbreviation of the `expiry` /
   `ExpirySelector` the codebase already says, and stays token-efficient.

6. **Designed twice, per OSS-ADR-0004** (the typed TS interface is the language; text is a
   projection): the builder form is `b.buy(qty, strike, right, expiry?)` / `b.sell(...)`,
   the text form is the `exp` tail. `print(parse(text))` stays byte-stable, and the feature
   is **purely additive** — the `exp` tail is emitted only when `expiry !== undefined`, so
   every pre-existing plan prints byte-identical bytes and the existing golden fixtures are
   unmodified.

7. **`#execFor` widens from symbol-only to a `(symbol, expiry)` key**, so `using.exec.expiry`
   is no longer silently dropped and a contradicting tenor cannot fill.

8. **DEFERRED, deliberately: re-keying the book.** Re-keying `foldBook` / `BookState` / the
   sim's `#books` by `(symbol, expiry)`, and turning `mark-to-model.ts`'s expiry assertion
   into a real multi-tenor lookup, is a **separate change**, sequenced after `kestrel-wcnd`
   (the tau-from-expiry fix) merges — wcnd is concurrently threading `BookState.expiry` as a
   scalar, and re-shaping it simultaneously would collide both textually and semantically.

## Consequences

- A leg can name its tenor, which is what the options cell-expansion (`ih5h`) and the
  options-native cube (`qga2`) require.
- **The cross-expiry contamination remains latent until decision 8 lands.** Authoring a
  second tenor is now expressible *before* the book can faithfully represent it; the
  `mark-to-model.ts` tripwire is what surfaces the boundary loudly rather than silently.
  This ADR does not close that hole — it names it and sequences it.
- Zero-padding is preserved through the round-trip (`2026-1-5` stays `2026-1-5`):
  `parseExpiry` keeps the raw digit text, and normalization happens only at comparison time,
  never in the AST.
- `CONTEXT.md` gains **Expiry selector** and **`exp` (the leg expiry marker)**; the
  vocabulary is now glossary-governed rather than ad hoc.
- Because `exp` is on the language's public surface, it is materially cheaper to revise now
  than after the OSS/0.8.0 cut.

## Alternatives considered

- **Positional, mirroring `USING exec SPY 0dte`.** Rejected: unsafe on a leg (decision 5) —
  it would silently swallow the following token, and a silent swallow is exactly the class of
  failure the fail-closed rule exists to prevent.
- **Instrument-level tenor only (no per-leg expiry).** Rejected: a straddle/calendar cell
  cannot express two tenors on one ticket, which blocks `ih5h`/`qga2`.
- **Default a missing expiry to `0dte`.** Rejected outright: a silent default is a silent
  lie about what the author asked for, and 0DTE is the *most* dangerous thing to assume.
- **A longer keyword (`expiry`).** Rejected on token-efficiency grounds; `exp` is the
  codebase's own abbreviation and the language is optimized for agent authorship.

## References

- OSS-ADR-0004 — the typed TS interface is the language; text is a projection (designed twice).
- `kestrel-ih5h` (options cell-expansion + engine seams), `kestrel-qga2` (options-native cube).
- `kestrel-wcnd` — tau from real expiry; decision 8 sequences after it.
- `src/lang/ast.ts` (`OptionLeg.expiry`, `ExpirySelector`), `src/lang/parse.ts` (`parseExpiry`),
  `src/engine/plans.ts` (`#execFor`), `src/bus/types.ts` (`foldBook` / `BookState`).
