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