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.

125 lines (104 loc) 7.35 kB
# OSS-ADR-0045 — The refusal-code union: an arm-time refusal is coded DATA, not a matched message string Status: Accepted — retro-authored 2026-07-16; the decision landed with the implementing code (cited in-source as OSS-ADR-0045) but the record was never committed; reconstructed verbatim-faithful from the implementation, not reinterpreted. ## Context When the plan engine refuses to arm a statement, a consumer needs to know *why* in a way it can route on — display one thing for a fixable typo, halt for a lineage collision. Before this decision a refusal was a human-readable message string, and the only way a consumer could tell one refusal apart from another was to match on that prose. That is a broken interface twice over: a wording change silently breaks every matcher, and there is no closed vocabulary the matchers can be checked exhaustive against, so a new refusal reason slips through as an unhandled string. Two concrete arm-time failures drove the shape: - **`unknown-series`.** A trigger names a case-variant of a known market fact (`VWAP` for `vwap`, `VELOCITY` for the windowed `velocity`). Series lookup had been case-*insensitive* forgiving (the 0.4.5 behavior): it armed a plan on a name the author never registered, then — because the org/market split routes the unregistered exact name to the ORG side where it reads UNKNOWN forever — the plan sat *armed but could never fire*, its only runtime signal a misleading "org path unresolvable." A silent de-arm the author could not see. This violates the runtime's standing doctrine: an unknown series is a **loud, repairable refusal at arm, never a silent default** (RUNTIME §8 — "unknown series → de-arm with reason"; "never default silently"). - **`plan-name-in-use`.** A revision re-declares a plan NAME still owned by a LIVE (managing / inventory-holding) prior record. Arming it would clobber the by-name ledger and garble the merged lifecycle trace — names are lineage. These are different in kind: one offending statement can be refused while its healthy siblings arm; the other must halt the whole document. A single string could not carry that distinction to a consumer safely. ## Decision **A refusal is DATA, owned by `src/protocol`.** 1. **Closed, wire-stable code vocabulary.** `ArmRefusalCode` is a closed union of reason codes (`"unknown-series" | "plan-name-in-use"`). A consumer routes on `code`; the human-readable `message` and the optional `repair` are DISPLAY only — never matched as an interface. A wording change is therefore never a breaking change. 2. **The refusal shape.** `ArmRefusal` carries `statement` (the offending plan's NAME), `code` (the wire-stable reason), `message` (the human diagnostic), and an optional `repair` — a concrete did-you-mean fix (for a case-variant, the canonical lowercase series name the author meant). 3. **Refuse the offender, arm the siblings.** `PlanEngine.armDocument` returns an `ArmReport = { armed: string[]; refusals: ArmRefusal[] }`: the names that armed, and the coded refusals for the statements that did not. An `unknown-series` refusal is collected per-statement; the offending statement is filtered out of the healthy view so its name/budget never enter the standing book, and its healthy siblings arm unchanged (with no refusals the document passes through byte-identical). Nothing healthy is aborted by a bad sibling; nothing is silently dropped. 4. **A whole-document refusal is a throw carrying its code.** `plan-name-in-use` would corrupt LIVE state, so it is thrown as an `ArmError` that carries `.code` (matched on the code, never the message) rather than returned in the report. The `armRefusalError` helper folds a set of collected refusals into one `ArmError` at a driver boundary that must fail loud — its message enumerates EVERY refusal so nothing is silently dropped, and it carries the structured `refusals` array through. 5. **Exact-match series lookup, reverting the forgiveness.** `SeriesRegistry.lookup` is EXACT-MATCH and case-SENSITIVE: `lookup("HOD")` misses exactly like `lookup("zzz")`. This reverts both the 0.4.5 case-insensitive forgiveness AND the silent runtime de-arm it enabled. The frozen phonebook makes the refusal STATICALLY knowable at arm time, so the offender is named as data before it ever runs. The author-lint flags the same case-variant at author time. 6. **Lockstep type + tuple with compile-time reverse-exhaustiveness.** `ARM_REFUSAL_CODES` is a runtime tuple `satisfies readonly ArmRefusalCode[]`, and a reverse-exhaustiveness proof type (`Exclude<ArmRefusalCode, (typeof ARM_REFUSAL_CODES)[number]>` constrained to `never`) fails the build if any code is added to the union without being added to the tuple. The runtime vocabulary can never silently drift from the type — the same both-ways closed-vocabulary guard `SCOPES` and `RECEIPT_KINDS` use. 7. **Consumers never exhaustively match, so the union can grow.** The vocabulary is extensible — add a code to the union and to `ARM_REFUSAL_CODES` in lockstep. Consumers must NOT `switch` on `code` without a `default`; because a refusal is routed on its code with a display fallback (an MCP surface returns a tool result with `isError: true` and `structuredContent.refusal.code`), a new code is an additive change, never a breaking one. ## Consequences - One home for the refusal vocabulary: `src/protocol` owns `ArmRefusalCode`, `ArmRefusal`, and `ArmReport`, and every consumer (engine validate, session paper/sim drivers, the MCP server/protocol surface, the CLI) imports it as the single source of truth. OSS-ADR-0046 later cites this union as one of the wire vocabularies the protocol package already owned. - A refusal's prose can be reworded freely; only the code is a contract. - An unknown series is caught loud at arm with a did-you-mean repair, never armed into a plan that can never fire and never silently de-armed at runtime (RUNTIME §8). - The graded (422) path and the run path surface the identical refusal message, because both read the same `#unknownSeriesRejection` — the arm-tier diagnostic carries the refusal's `message` verbatim (the 422≡run contract). - Growing the vocabulary is a lockstep two-line edit that the build enforces; omitting either half fails to compile. ## Provenance Status: Accepted — retro-authored 2026-07-16; the decision landed with the implementing code (cited in-source as OSS-ADR-0045) but the record was never committed; reconstructed verbatim-faithful from the implementation, not reinterpreted. The implementing change is `feat(engine): arm returns coded ArmReport + exact-match series lookup` (kestrel-jwnh, OSS-ADR-0045 B2). Sources of record: `src/protocol/index.ts` ("arm refusals (OSS-ADR-0045)" — `ArmRefusalCode`, `ARM_REFUSAL_CODES`, the reverse-exhaustiveness proof, `ArmRefusal`, `ArmReport`); `src/engine/plans.ts` (`armDocument`, `ArmError`, `armRefusalError`, `#unknownSeriesRejection`); `src/series/registry.ts` (exact-match `lookup`); `src/engine/validate.ts`, `src/session/paper.ts`, `src/session/sim.ts`, and `src/mcp/server.ts` / `src/mcp/protocol.ts` (consumers routing on `code`). This ADR documents only what that code does.