Skip to main content
Glama
playbattlegrid

BattleGrid MCP Server

Official
README.md
# @battlegrid/mcp-server

[![npm version](https://img.shields.io/npm/v/@battlegrid/mcp-server)](https://www.npmjs.com/package/@battlegrid/mcp-server)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)

MCP server for [BattleGrid](https://battlegrid.trade) — play crypto prediction games, author trading strategies, and manage intelligence agents from AI agents.

It is a thin, authenticated **stdio proxy** to BattleGrid's remote MCP server (Stripe `@stripe/mcp` pattern — no business logic). It discovers tools, prompts, and resources live from the server and re-exposes them to local MCP clients (Claude Desktop, Claude Code, Cursor). Capabilities are always **discovered live** — this package never hardcodes the tool catalog.

## v31 — the announced contract is read from the server, not declared here

**The package version no longer tracks the server's contract, and no longer claims to.** Through v30 it did: the proxy announced `battlegrid@<package version>` downstream, so the published number was read as the contract a client would reach, and keeping the two in step was a manual release chore. It did not hold — the package sat at `11.0.0` against a deployed contract of `19.3.0` for ten days, `5.1.0` was declared here and never published at all, and four of the last five releases were version bumps carrying no code change.

From v31 the proxy reads the contract version out of the upstream handshake at connect time and relays it verbatim. What a local client reads is therefore what the connected server just announced — correct by construction, on every connection, with no release involved.

**Two versions, two meanings, and they are expected to differ:**

| | What it describes | Where to read it |
|---|---|---|
| Package version | This proxy's own code — a fix here, a dependency bump, a docs correction | `npm view @battlegrid/mcp-server version` |
| Contract version | The server's wire contract, live | The stdio handshake (`battlegrid@<contract>`), or `GET /mcp/version` |

Seeing package `31.x` alongside handshake `battlegrid@33.x` — the package **behind** the contract — is the system working, not a missed release. That is the pair that looks alarming and is not: the contract moved, and no release here was needed. Both numbers are printed to stderr at startup, labelled.

**What this changes for you:** nothing about how you call anything. Upgrading the package no longer waits on a server deploy, and a server deploy no longer strands you on a package that names the wrong contract — reconnect and the announcement follows. **Contract breaking-change notes are no longer keyed to package versions**, since a contract move is no longer a release here; the v11-and-earlier notes below are kept as history, and the live vocabulary is always discovery.

## Contract history — v76 (the extreme funding signals in percent a year)

**Breaking at v76: `funding_extreme_positive` and `funding_extreme_negative` take their threshold in
percent a year.** Their parameter is `thresholdAnnualizedPct`, the unit of the strategy grammar's
`ann` column, where it was `thresholdPct`, a fraction of one hourly funding settlement.

### Rejected input — the retired `thresholdPct`

- **Both signals take `thresholdAnnualizedPct`**, bounded 1–43,800 and defaulting to 100 — the
  funding label's crypto `extreme` band, so `100` means 100% a year, and the negative signal fires
  below -100%.
- **`thresholdPct` on either signal is refused as `VALIDATION_ERROR`** wherever a rule's parameters
  are authored: `compile_strategy_plan`, `update_strategy_signal_rule`, `derive_strategy_rule_view`
  and a strategy draft's `SIGNAL_RULES` axis. `oi_surge` keeps its own `thresholdPct`.
- **A body accepted at v75 can be refused at v76 without one byte of it changing.** A rate per hourly
  settlement converts to percent a year by × 8,760 × 100: `0.0002` is `175.2`.

### Stale plans

- **A plan token compiled before the deploy fails as a stale plan** at `stage_strategy_plan` and
  `apply_strategy_plan`, because the authoring catalog digest covers each signal's parameters,
  defaults and explanation: compile again. The strategy preview's `vocabularyDigest` moves too.

### Served values

- **`get_strategy_signal_definition` and `list_strategy_signals` serve the new parameter**, its
  default and its explanation.
- **Each signal's indicator values gain `funding_annualized_pct`**, the annualized rate the signal
  compared, beside `funding_rate`.
- **Stored rules move to the new key after the deploy**: a rule at the retired default takes `100`,
  and any other converts exactly. `get_coin_signal_preview` and every stored rule then score at the
  new threshold.
- **No schema hash moves.** Signal parameters are published as a JSON object on every input and
  output, so the change is in the values accepted and served, and `toolCount` is unchanged.

## Contract history — v75 (readability from each coin's data profile)

**Breaking at v75: the seven tools v73 narrowed refuse by the coin's market-data profile, and one
output vocabulary gains a member.** Which data a coin carries is now the profile the market-data
service publishes for that coin, no longer the coin's asset class.

### Rejected input — the seven tools v73 narrowed

- **A condition reading a kind of data the coin's profile does not serve is refused as
  `INSTRUMENT`**, on the coin or on a benchmark section's own instrument: a spot read on a crypto
  coin no spot venue lists is refused as one on a TradFi coin always was.
- **A coin the market-data service publishes no profile for refuses every condition that reads
  market data**: a coin enabled since the service's last boot, a roster coin whose candle stream it
  did not subscribe, a ticker with no catalog row.
- **The tools, codes and `details.context` are v73's**: `preview_radar_resolution` (every kind,
  issuing no `previewToken`); `commit_radar_deployment_draft`, `rebind_intelligence_agent`,
  `compile_strategy_plan` and `restore_strategy` (`VALIDATION_ERROR` with
  `CONDITION_UNREADABLE_BY_RADAR_SCAN`); `apply_strategy_plan`, which applies only what its compile
  checked; and `propose_entry_decision` (`CONDITION_UNREADABLE_ON_COIN`).
- **A body accepted at v74.1.1 can be refused at v75 without one byte of it changing.** A write whose
  read of the published profiles fails is refused, never admitted unchecked.

### Reclassified reason — `FEED` becomes `INSTRUMENT`

- **A spot read for a ticker with no catalog row is `INSTRUMENT`, where it was `FEED`**, and an
  uncatalogued benchmark refuses every market-data read, where only its spot reads refused before.
- **The `INSTRUMENT` message names what the profile lacks**: "`<instrument>`'s market-data profile
  carries no `<kind>` data", or "the market-data service publishes no data profile for
  `<instrument>`, so it carries no market data", where it read "`<instrument>` has no spot tape".
- **The readings v74 added report it as the sweep does**: `preview_radar_resolution`'s `readings`
  and `get_agent_coin_qualification`'s `reading` mark a condition on a kind the coin's profile lacks
  `INSTRUMENT`.

### Wider output — `BarFamily` gains `MARKET_STATS`

- **The market-stats snapshot is a per-bar value**: a decision reads the snapshot fetched inside the
  `1m` bar ending at its close. A snapshot missing there is a per-bar shortfall, a `families` entry or
  a MISSED close's `INPUTS_OFF_BAR` `lacking` entry naming `MARKET_STATS` at `1m` with its
  `dueBarStart`, `ABSENT` or `GONE`, and the decision waits or misses as for any per-bar family.
- **A decided close records no `MARKET_STATS` stale sample.** A reading at the latest values still
  does, and its `boundMs` is the coin's market-stats freshness target, never below 300,000 ms.
- **Five outputs publish the member**: `get_agent_coin_qualification`, `get_radar_close_decisions`,
  `get_signal_log`, `list_gate_blocks` and `preview_radar_resolution`. No input takes the vocabulary.
  A client that validates results against a cached output schema should list the tools again after
  the server deploys.

## Contract history — v74.1.1 (a same-agent request answered from what stopped the fire)

**Not breaking: one answer corrected behind unchanged schemas.**

- **A request on a pair your own radar policy decides with the same agent** is answered from that
  policy's decision. When the policy's fire enqueued no trade decision and no admission gate refused
  it — a coin cooldown, the hourly fire cap, post-close suppression, a decision already running on the
  coin, a deduplicated fire, or a fire aborted at its re-read — the conversation gains the card the
  request's own unfired fire gets: `close_claimed_by_radar` when another agent's fire holds the coin,
  otherwise `close_refused` with the reading's score against its minimum. It gained
  `close_fired_by_radar` before, though no trade was taken.
- **Both cards are already in `get_trade_conversation`'s vocabulary**, so no served shape moves.

## Contract history — v74.1 (missed-bar alerts and the entry pause)

**Not breaking: four output vocabularies gain a member, and `propose_entry_decision` refuses while an
entry pause is open.** While bars missed for lack of market data span many coins, the platform pauses
every new entry the radar decides, a radar fire and a manual request alike, and the pause lifts by
itself after a quiet period.

### Refused while a pause is open — `propose_entry_decision`

- **A request is refused after its own checks and before a watch is registered**, whatever the agent
  or coin: `type: "error"` with `{ origin: "ENGINE", reasonCode: "ENTRIES_PAUSED" }`. The same
  `idempotencyKey` replays the refusal for five minutes; ask again under a fresh key once the pause
  lifts.
- **A request queued before the pause** whose close qualifies while it is open gets the same ENGINE
  error card in its conversation, and no decision is proposed. `accept_entry_decision` on a decision
  already proposed, and every exit, run as without a pause.

### Wider output — four vocabularies

- **`TradeEvaluationAttemptReasonCode` gains `ENTRIES_PAUSED`**: the refusal's `reasonCode`,
  `list_gate_blocks`' `reasonCode`, and the `scanBlockReason` of a refused fire's close record and
  journal row.
- **`TradingPipelineGateStage` gains `PLATFORM`**: a `list_gate_blocks` entry for each fire the pause
  refused, with no coin, no detail and no decision record.
- **`RadarFireDisposition` gains `EDGE_PRESERVED_ENTRIES_PAUSED`**: the refused fire keeps its edge,
  so the pair fires at a later close only if that close qualifies on its own data. It is served on
  `get_radar_activity` and `get_radar_activity_summary`, and as a close record's
  `outcome.fireDisposition` beside `outcome.scanBlockReason: "ENTRIES_PAUSED"`, with
  `outcome.outcome` staying `FIRED`.
- **`RadarIdleReason` gains `ENTRIES_PAUSED`**: the `resolvesNow.reason` of every `SCANNING` card on
  `get_radar_deployment` and `list_radar_deployments` while a pause is open. The section stays
  `SCANNING`, because closes are still decided and recorded.

### Answer corrected — a same-agent request

- **When an admission gate refused your own policy's fire** — an entry pause, an account block, a
  position already held on the coin — the request's conversation gains that block's ENGINE error card
  instead of `close_fired_by_radar`.

## Contract history — v74 (preview `conditionReach` replaced by `readings`)

**Breaking at v74: one required output field replaced on one tool, and an opt-in reading added to
another.** Both now serve what an agent reads on a coin, in the decision-record format v73.3
introduced.

### Reshaped output — `preview_radar_resolution`

- **`conditionReach` is gone, replaced by `readings`**: one entry per on-duty agent, in on-duty order,
  each naming `agentId` and `agentDisplayName`. A `kind: "READ"` entry carries the agent's `reading`,
  the one `get_agent_coin_qualification` serves (below). A `kind: "UNSCORABLE"` entry, an agent whose
  evaluation was rejected, carries `coinDataStopped` instead: the coin's stopped-data reading, or
  null. One agent's rejection is its own entry, never a failed preview. The tool always asks for
  readings, so `readings` is null only under a `simulatedRegime`.
- **`blocksScanGate` has no successor.** Since v73 the preview refuses a slot agent with a condition
  the radar scan cannot read on the coin, so no previewed pair holds a permanently blocking
  condition. Whether the conditions hold the coin is the reading's own gate,
  `reading.live.reading.qualification.gates.requiredConditions`.
- **Read `reachReason` from the reading.** Each `READ` entry's
  `reading.live.reading.conditions.entries[]` lists the conditions: a `kind: "UNEVALUATED"` entry
  carries the condition's `conditionKey`, `name` and `reachReason`, and a `kind: "EVALUATED"` entry,
  the counterpart of a null `reachReason`, carries its outcome with its clause values.
  `declaration.required` marks the required ones, the set `conditionReach` covered.
- **A client that validates results against a cached output schema** fails the call until it lists
  the tools again, because the old schema required `conditionReach`. Reconnect, or re-list, after the
  server deploys.

### Wider input and output — `get_agent_coin_qualification`

- **`reading` (boolean, default false) asks for the pair's reading**, and each verdict gains the
  required-nullable `reading`, null unless asked. A call without the flag is answered as before, with
  `reading: null`.
- **`reading.live`** is the reading the verdict was built from, with `anchorBarStatus` (`live` while
  the anchor bar still forms), `scoredBarStart` (the bar the signals and gates were scored on) and
  `observedAt`. It is what the agent reads now, not a decision: the radar decides at the close.
- **`reading.lastClose`** is the deciding reading of the bar the agent's on-duty row names as its
  last close decision: `state: "RECORDED"` with that bar's close-decision `record`,
  `state: "PENDING"` with its `barStart` while that record is being written, or `state: "NONE"`.
  `get_radar_close_decisions` holds the decisions before it.

## Contract history — v73.3 (decision records)

Purely additive: **one read tool, one evaluation-attempt reason and four output fields added.** What
a decision read is now served: the radar's close decisions on a coin, the report behind a trade, and
the compose decision behind a gate block.

### Wider surface — one tool added

- **`get_radar_close_decisions`** (`coinId`; optional `agentId`, `before`, `limit`; `mcp:read`) — one
  coin's radar close-decision records, newest decided bar first: every bar an agent decided —
  `FIRED`, `CLAIMED`, `NOT_QUALIFIED` or `MISSED` — with the reading it was decided on (a miss's
  answer instead), its fire disposition and its versions. Your own records only: a SYSTEM agent's
  records on a coin belong to every user who deployed it. A page never splits a bar, so it can run
  past `limit`; to page back, pass the oldest record's `bar.barStart` as `before`. `toolCount` goes
  141 → 142.

### Wider output — the risk budget, Radar reads, the trade conversation, gate blocks and signal logs

- **`TradeEvaluationAttemptReasonCode` may read `SCREENED_OUT`** on `get_agent_budget`,
  `reset_agent_drawdown_baseline`, `get_radar_activity`, `get_radar_deployment`,
  `list_radar_deployments`, `preview_radar_resolution`, `get_trade_conversation`,
  `propose_entry_decision`, `list_gate_blocks`, `get_signal_log` and
  `get_public_agent_signal_log_detail`: compose's deterministic pre-model screen found no routable
  direction. That refusal now writes a gate block. It is public: the public profile carries it as is.
  A client holding its own closed copy of the enum rejects the new member; one that renders unknown
  reasons generically is unaffected.
- **`get_signal_log`'s `log` gains `reportSections`** (the report the model read, section by section,
  in the shape of `preview_strategy_report`'s `renderedSections`), **`promptDataSerialization`** (the
  serialization it was rendered under) and **`missingData`** (the data compose lacked), each null for
  a trade recorded before capture. `get_public_agent_signal_log_detail` carries none of them.
- **Each `list_gate_blocks` entry gains `decisionRecord`**: the compose decision record written with
  the block — its outcome, its evidence (the reading compose computed, the per-bar values a refused
  decided bar lacked, or none), its missing data and its versions. It is null for a block written
  before capture and for a block raised before compose.

## Contract history — v73.2 (decided-bar refusal)

**One member added to the evaluation-attempt reason**, published as output only, and two detail
fields added to gate blocks. A confirming-close fire now names the bar it decided, and compose reads
that bar or refuses with the new reason, instead of reading the newest completed bar at its own
instant.

### Wider output — the risk budget, Radar reads, the trade conversation, gate blocks and signal logs

- **`TradeEvaluationAttemptReasonCode` may read `DECIDED_BAR_UNAVAILABLE`** on `get_agent_budget`,
  `reset_agent_drawdown_baseline`, `get_radar_activity`, `get_radar_deployment`,
  `list_radar_deployments`, `preview_radar_resolution`, `get_trade_conversation`,
  `propose_entry_decision`, `list_gate_blocks`, `get_signal_log` and
  `get_public_agent_signal_log_detail`: compose refused a radar fire at the conditions stage, before
  any LLM call, because a per-bar value its conditions gate reads was not on its due bar for the bar
  the radar decided — its entry had not landed, or had gone. It is public: the public profile carries
  it as is. A client holding its own closed copy of the enum rejects the new member; one that renders
  unknown reasons generically is unaffected.
- **`list_gate_blocks`' `reasonDetail` gains `decidedBarStart`** (ISO 8601, the decided bar's open)
  **and `decidedBarTimeframe`** (the strategy timeframe), present on that reason alone.

## Contract history — v73.1 (strategy card fields)

Purely additive: **thirteen read-only fields added to every `list_strategies` row.** The input is
unchanged, and a client that ignores the fields is unaffected.

### Wider output — `list_strategies`

- **The strategy card:** `regimeTimeframe` and `lowerTimeframe` (the derived ladder rungs
  `get_strategy` already serves); `conditionTally`, `signalRuleTally` and `exitTally` (`armed` out of
  `total`, the four exit mechanisms); and `minRequiredCount`, `entryTrigger`, `minRiskRewardRatio` and
  `maxStopLossAtrMultiple`.
- **`forkedFromStrategyName`**, null when the strategy is not a fork or its source is neither yours
  nor a SYSTEM strategy.
- **`viewerBoundAgentCount` and `viewerOpenPositionCount`**, your own agents on the strategy and their
  open positions. `boundAgentCount` still counts every user's agents; read the viewer fields for
  yours.
- **`viewerPerformance`**, your own all-time results on the strategy, null when you never traded it.
  A trade counts toward the strategy recorded on the signal log that opened it, so rebinding an agent
  never moves it.

## Contract history — v73 (unreadable radar conditions refused)

**Breaking at v73: seven tools refuse bodies they used to accept, behind unchanged input schemas.**
A condition the radar acts on — a required one, a direction-setting one (a verdict carrier), an exit
one, and every condition they reference — must be readable by the radar scan on every coin the radar
acts on for its agent: each coin a Radar policy slots it on, enabled or paused; each coin it holds an
open position on, for its exit conditions; and each coin it has a pending manual request on.

### Rejected input — refused behind unchanged schemas

- **`VALIDATION_ERROR` with the authoring code `CONDITION_UNREADABLE_BY_RADAR_SCAN`** from
  `preview_radar_resolution` (every kind, for every slot agent whether on duty now or not, issuing no
  `previewToken`), `commit_radar_deployment_draft`, `rebind_intelligence_agent`, and
  `compile_strategy_plan` and `restore_strategy` on behalf of the bound agents the radar acts on (a
  strategy none of whose agents holds a slot, an open position or a pending request is never
  checked). `details.context.reachReason` says why: `INSTRUMENT` (the coin, or a benchmark section's
  own instrument, has no such data), `AGENT_TIMEFRAME` (the agent has no such rung) or `FEED` (the
  radar scan never reads that data: crowd reads, a spot read for an uncatalogued ticker, a timeframe
  the agent's plan does not load).
- **The message names the first conflicting agent, the condition, the column, the coin and the
  fixes**: stop the radar acting on the condition, make it read a column the scan has on that coin,
  or free the coin — take the agent off that coin's policy, close the position, or cancel the pending
  request. It is a fact about the pair, which no later sweep changes.
- **A refused compile issues no plan token**, so `stage_strategy_plan` cannot stage that content, and
  **`apply_strategy_plan` applies only a plan whose bound agents still hold the radar slot coins its
  compile checked**: a plan compiled before a deploy moved them fails verification. Compile again.
- **A condition reading a Session Field scalar** keeps `CONDITION_OPERAND_UNSERVED_IN_LANE`, now
  refused at the rebind and every strategy revision too, not only at the deploy.
- `stage_radar_deployment_draft` is unchanged: a draft holds no slot, and the preview its commit
  needs refuses. A body accepted under v72 is refused under v73 without one byte of it changing.

### Wider output — `propose_entry_decision`, `get_trade_conversation`

- **`propose_entry_decision` refuses a request whose agent the radar scan cannot read on the
  requested coin**, before its watch is registered, and releases its idempotency claim. It returns
  `type: "error"` with `error: { reasonCode: "CONDITION_UNREADABLE_ON_COIN", unreadable: {
  conditionKey, conditionName, column, coinTicker, reachReason } }`: facts only, no prose. Choose
  another coin, another agent, or a condition reading a column the radar reads on that coin, and
  send it again. `get_trade_conversation`'s error card carries the same `reasonCode` and
  `unreadable`.
- **A client that validates results against a cached output schema** fails a call that returns the
  new error until it lists the tools again, because the old schema has no such member. Reconnect, or
  re-list, after the server deploys.

### Refusals worth knowing before you apply, restore or rebind

- **`CONFLICT` with `details.reason: "RADAR_DEPLOYMENT_MOVED"`** from `apply_strategy_plan`,
  `restore_strategy` and `rebind_intelligence_agent`: the agents' radar slot coins moved after the
  write's check. Re-read and retry, so the retry's check covers the moved deployment; for a plan,
  compile again.
- **`commit_radar_deployment_draft` is a `CONFLICT`** when a slot agent's strategy or timeframes
  changed after its check. Preview again.

## Contract history — v72.0.1 (account read faults withheld on the public profile)

**No schema change: one value reclassified on one public read.**

### Reclassified output — `get_public_agent_signal_log_detail`

- **`pipeline.attempt.reasonCodes` carries `OWNER_PRIVATE`, with its detail omitted, where it would
  have carried `EQUITY_CHECK_UNAVAILABLE`, `ALLOCATION_CHECK_UNAVAILABLE`,
  `DAILY_COUNT_CHECK_UNAVAILABLE` or `APPROVAL_CHECK_UNAVAILABLE`.** Each says the account has an open
  block, which the public profile withholds. This supersedes the v71.1 note below that the public
  detail carries the two faults v71.1 added. `OWNER_PRIVATE` has been in that field's schema since
  v71, so a client validating against a cached output schema accepts it. `get_signal_log` still
  carries every precise code on your own agents.

## Contract history — v72 (public decision `userId` removed)

**Breaking at v72: one output field removed from one public read.**

### Removed output — `get_public_agent_signal_log_detail`

- **`log.linkedEntryDecision.userId` is gone.** It was the user the agent acted for. On a SYSTEM
  agent, which has no author and which any user may deploy, that named whoever deployed it, and the
  public profile deliberately shows no owner for a SYSTEM agent. There is **no replacement path** on
  the public read. A PRIVATE agent's author is `ownerUserId` on the agent's public profile, and the
  owner tool `get_signal_log` still carries `linkedEntryDecision.userId` on your own agents.
- **A client that validates results against a cached output schema** fails the call until it lists
  the tools again, because the old schema required the field. Reconnect, or re-list, after the server
  deploys.

## Contract history — v71.1 (two read-fault refusals)

**Two members added to the evaluation-attempt reason**, published as output only. When the server
cannot read an agent's daily trade count or its trading account's approval, the admission is blocked
with its own reason instead of borrowing the verdict it could not reach.

### Wider output — the risk budget, Radar reads, the trade conversation, gate blocks and signal logs

- **`TradeEvaluationAttemptReasonCode` may read `DAILY_COUNT_CHECK_UNAVAILABLE` or
  `APPROVAL_CHECK_UNAVAILABLE`** on `get_agent_budget`, `reset_agent_drawdown_baseline`,
  `get_radar_activity`, `get_radar_deployment`, `list_radar_deployments`, `preview_radar_resolution`,
  `get_trade_conversation`, `propose_entry_decision`, `list_gate_blocks`, `get_signal_log` and
  `get_public_agent_signal_log_detail`. `DAILY_TRADE_LIMIT_REACHED` and `AGENT_APPROVAL_EXPIRED` still
  mean what they say. A client holding its own closed copy of the enum rejects the new members; one
  that renders unknown reasons generically is unaffected.

## Contract history — v71 (owner-private reasons on the public profile)

**Breaking at v71: two public inputs narrow, and one public output widens.**

### Rejected input — `get_public_agent_signal_logs`, `get_public_agent_realized_trades`

- **`filter.rejectionReason` refuses the six owner-private failure reasons** —
  `BELOW_EXCHANGE_MINIMUM`, `INSUFFICIENT_BALANCE`, `INSUFFICIENT_MARGIN`,
  `AGENT_APPROVAL_EXPIRED`, `SIGNING_KEY_UNAVAILABLE`, `AGENT_HALTED` — with a validation error,
  where they used to filter. These tools read another user's agent, and a private reason answered a
  question about that user's budget, margin, approval, key or halt. No alias exists; filter by a
  public reason. `filter.expiryReason` still accepts every member.

### Wider output — `get_public_agent_signal_log_detail`

- **`pipeline.attempt.reasonCodes`, `pipeline.execution.failureReason` and `.expiryReason` may read
  `OWNER_PRIVATE`** in place of an owner-private reason, whose detail is then omitted. A client
  holding its own closed copy of these enums rejects the new member; one that renders unknown
  reasons generically is unaffected. A failed execution's `executionMessage` is now always null.
- **`get_signal_log` is unchanged**: on your own agents it still carries every precise code, its
  detail and the narrative.

## Contract history — v70.2 (allocation-check refusal)

**One member added to the evaluation-attempt reason**, published as output only. An admission the
server blocked because it could not read the account's allocation now carries
`ALLOCATION_CHECK_UNAVAILABLE`; `NO_AGENT_ALLOCATION` survives only on historical rows.

### Wider output — the risk budget, Radar reads, the trade conversation and gate blocks

- **`TradeEvaluationAttemptReasonCode` may read `ALLOCATION_CHECK_UNAVAILABLE`** on
  `get_agent_budget`, `reset_agent_drawdown_baseline`, `get_radar_activity`, `get_radar_deployment`,
  `list_radar_deployments`, `preview_radar_resolution`, `get_trade_conversation`,
  `propose_entry_decision` and `list_gate_blocks`. A client holding its own closed copy of the enum
  rejects the new member; one that renders unknown reasons generically is unaffected.
- **The reason detail no longer declares `availableUsd` or `requiredUsd`** (`get_signal_log`,
  `get_public_agent_signal_log_detail`). No response ever carried them.

## Contract history — v70 (risk-gauge `configured` removed)

**Breaking at v70: one output field removed from every risk-budget gauge.**

### Reshaped output — `get_agent_budget`, `reset_agent_drawdown_baseline`

- **`budget.gauges.<gauge>.configured` is gone** from all four gauges (`dailyTrades`, `exposure`,
  `drawdown`, `dailyLoss`). It could only read `true`: the daily-trades cap is at least 1, and an
  agent's exposure ceiling, drawdown stop and daily-loss stop are all required and strictly positive.
  Every gauge has a limit, so read `fill`, `remaining` and `breached` and render every meter enabled.
- **A client that validates results against a cached output schema** fails the call until it lists
  the tools again, because the old schema required the field. Reconnect, or re-list, after the server
  deploys.

## Contract history — v69.7 (insufficient-margin refusal)

Purely additive: **one member added to the trade-failure reason**, in both directions. An agent entry
the shared trading account cannot post the isolated margin for is now refused before any order is
sent, with its own reason. The same reason replaces `EXCHANGE_ERROR` when the exchange refuses an
order for margin first.

### Wider input — `get_public_agent_signal_logs`, `get_public_agent_realized_trades`

- **`filter.rejectionReason` accepts `INSUFFICIENT_MARGIN`**, where it used to refuse it.

### Wider output — `get_signal_log`, `get_public_agent_signal_log_detail`

- **`failureReason` may read `INSUFFICIENT_MARGIN`.** A client that switches exhaustively on the
  reason needs one more arm; one that renders unknown reasons generically is unaffected.

## Contract history — v69.5 (liquidation-buffer refusal)

Purely additive: **one member added to the trade-failure reason**, in both directions. An agent entry
whose hard stop no whole leverage can keep clear of liquidation is now refused before any order is
sent, with its own reason. Below that, an agent order's reported effective leverage may sit under the
agent's bound: it is lowered until the isolated liquidation is at least twice as far from the fill as
the hard stop.

### Wider input — `get_public_agent_signal_logs`, `get_public_agent_realized_trades`

- **`filter.rejectionReason` accepts `LIQUIDATION_BUFFER_UNREACHABLE`**, where it used to refuse it.

### Wider output — `get_signal_log`, `get_public_agent_signal_log_detail`

- **`failureReason` may read `LIQUIDATION_BUFFER_UNREACHABLE`.** A client that switches exhaustively on
  the reason needs one more arm; one that renders unknown reasons generically is unaffected.

## Contract history — v69 (Radar multi-agent duty)

**Breaking: one input removed from two tools, three outputs reshaped.** A Radar policy carries no
timeframe. Every rule whose conditions match puts its agent on duty at once, and each rule's regime
condition is read at that agent's own regime timeframe. A coin still holds one position at a time: at
most one fire per coin per pass, offered in rule priority.

### Rejected input — something you send is no longer accepted

- **`preview_radar_resolution`'s `SLOTS` request refuses `deploymentTimeframe`** as an unrecognized key.
  Send `{ kind: "SLOTS", slots }`.
- **`stage_radar_deployment_draft` refuses the `DEPLOYMENT_TIMEFRAME` axis** the same way. Stage only
  `RULES` and `DEFAULT_SLOT`.

### Reshaped output — something you read has a new shape

- **`resolvesNow`** on `get_radar_deployment`, `list_radar_deployments` and `preview_radar_resolution` is
  the coin's state (`section`, `isIdle`, `rotating`, one `reason`, the open-position owner) plus
  **`onDuty`**: one row per agent on duty, in priority order. Each row carries its slot, its regime reading
  at its own regime timeframe, its qualification verdict and gate, cooldown, block, `edgeSpent`, last flip
  and fire, and its own `closeDecision`. The single-winner fields (`onDutyAgentId` and its display pair,
  `matchedSlot*`, `regimeUsed`, and the per-agent fields the rows now carry) are gone. Render every row in
  the order served, and never pick one yourself.
- **`preview_radar_resolution`'s `conditionReach`** is one flat list, each entry naming its `agentId` and
  `agentDisplayName`.
- **Each deployment slot gains `agentRegimeTimeframe`**, the timeframe its rule's regime is read at.

### Removed output — a field you read is gone

- **`deploymentTimeframe`** on a Radar policy, and the **`DEPLOYMENT_TIMEFRAME`** axis in the radar
  draft's `content` and `axesMeta` on `get_radar_deployment_draft`, `list_radar_deployment_drafts` and the
  stage result.

### Wider enums — a value you may now receive

- **`closeDecision.outcome` gains `CLAIMED`**: the agent's close qualified, but a higher-priority agent
  took the coin's one fire that pass. Its edge is preserved, and it fires at a later close only if it
  still qualifies there.
- **`get_radar_activity` and `get_radar_activity_summary`** gain the events `ON_DUTY_JOINED` and
  `ON_DUTY_LEFT` (duty is journaled per agent) and the fire disposition `EDGE_PRESERVED_COIN_CLAIMED`.
  `curveDigest` describes the first agent on duty, named in `curveAgentName`.
- **`get_trade_conversation`** may carry the `close_claimed_by_radar` card.

The exported `battlegrid-radar-deployment` skill describes the v69 flow.

## Contract history — v68.1 (Arena regime sets)

Purely additive in schema, with **one refusal behind unchanged ones.** An Arena rule's regime condition
is a SET in the draft, as it always was in the committed deployment: one rule can fire in several
regimes, instead of one rule per regime.

### Wider input — `stage_deployment_policy_draft`

- **A rule's `regimes` may name several regimes**, where the draft capped it at one. The set is stored in
  the selectable-regime order, so a staged `[VOLATILE, BULL_EXPANSION]` reads back
  `[BULL_EXPANSION, VOLATILE]`. A regime named twice is refused.

### Wider output — `get_deployment_policy_draft`

- **The draft may return such a set.** `get_deployment_policy` has served sets all along, so a client that
  reads a committed deployment already handles them.

### Refusals behind unchanged schemas

- **`preview_deployment_resolution` (SLOTS), `preview_radar_resolution` (SLOTS) and
  `test_generate_deployment_grid` refuse a regime condition naming one regime twice**, as
  `VALIDATION_ERROR` at its `regimes` field, where they used to resolve or generate over it. The stage tools
  already refused a repeat, so no commit changes; a repeated member is a malformed set.

## Contract history — v68 (Arena drafts)

**Breaking: one tool retired, two inputs reshaped.** Arena deployment content now commits only as a
draft the player was shown a live preview of, as radar content has since v66. The player's deploy
editor and every conversation share one unsaved draft per arena.

### Removed tool — something you call no longer exists

- **`upsert_deployment_policy` is retired**, with no alias. Calling it is an unknown-tool error. To
  deploy or change an arena's deployment:
  1. `stage_deployment_policy_draft` the axes you are changing (`RULES`, `DEFAULT_SLOT`,
     `REGIME_ANCHOR`), with the `draftVersion` your last read returned;
  2. `preview_deployment_resolution` with `request: { kind: "DRAFT", draftVersion }` at the version
     staging returned;
  3. show the player the resolution and whether it will play (`enabledAfterCommit`), and on their word
     call `commit_deployment_policy_draft` with the `previewToken` that preview returned and
     `confirm: true`.

  Pausing is no longer a flag on the write: use `pause_deployment_policy`. **A first deployment always
  plays**; a replacement keeps its pause.

### Rejected input — something you send is no longer accepted

- **`preview_deployment_resolution`'s `request` needs a `kind`.** `{ kind: "SLOTS", slots, … }` is the
  old request. `{ kind: "DRAFT", draftVersion }` previews the player's draft of the arena at that
  version, composed over the deployed policy exactly as the commit writes it. `{ kind: "COMMITTED" }`
  previews the deployed policy a resume would arm. A request without `kind` is refused.
- **`delete_deployment_policy` requires `expectedPolicyId`** beside `expectedRevision`: a revision
  restarts at 1 when an arena is redeployed, so only the pair names the deployment you read.
- **A regime anchor override names its coin and timeframe together, or neither**, on every path
  including `test_generate_deployment_grid` and the SLOTS preview.

### Wider surface — eight tools added

- **`stage_deployment_policy_draft`**, **`get_deployment_policy_draft`**,
  **`list_deployment_policy_drafts`** and **`discard_deployment_policy_draft`** (`mcp:read`). None of
  them commits or arms anything.
- **`commit_deployment_policy_draft`** (`presetId`, `previewToken`, `confirm: true`; `mcp:wager`) — the
  only MCP committer of Arena content.
- **`pause_deployment_policy`** (`presetId`; `mcp:wager`) — no revision and no certificate.
- **`resume_deployment_policy`** (`presetId`, `previewToken`, `confirm: true`; `mcp:wager`) — needs the
  certificate a live `COMMITTED` preview returned.
- **`cancel_market_grid_submission`** (`sessionId`, `confirm: true`; `mcp:wager`) — the player's own
  cancellation and refund of one entry.

### Reshaped and wider output

- **`preview_deployment_resolution`** nests the resolution under `resolution` and gains
  **`previewToken`** (the certificate a live DRAFT or COMMITTED preview earns, null otherwise, good for
  five minutes) and **`enabledAfterCommit`**.
- **`pause_deployment_policy` and `delete_deployment_policy` return `openEntries`**: the entries the
  player's agent already made in the arena's pending sessions, each with its lock time and entry fee.
  They play out unless the player cancels them — tell the player and ask, then call
  `cancel_market_grid_submission` only for the entries they pick.
- **`get_deployment_policy`'s `authoringContext`** gains the arena's header facts
  (`regimeReferenceTicker`, `presetBadgeImageUrl`, `entryFee`, and `playerCount` — null when the arena
  has no pending session to count).
- The thought-log **`outcome`** gains **`SKIPPED_DEPLOYMENT_DISARMED`**: an entry job that found its
  deployment withdrawn, paused or no longer slotting its agent stood down before paying.

### Refusals worth knowing before you commit or resume

- **The certificate is bound to what you previewed**: your credential, the player, the arena, the
  subject, the deployment and its revision, the draft version and the composed content. Anything that
  moves is a `CONFLICT` and nothing is written. Preview again; never reuse the old certificate.
- **A commit never withdraws**: a draft with no rule and no catch-all is refused, naming
  `delete_deployment_policy`.
- **Resume is refused on a retired arena and on one the player's access was revoked from**; pausing and
  withdrawing stay open.
- **Deployment refusals answer `INVALID_DEPLOYMENT_POLICY`.**
- **`delete_deployment_policy` also ends the player's draft of the arena.**

### Vocabulary

`toolCount` goes 134 → 141 (eight added, one retired). `preview_deployment_resolution`'s input and
output, `delete_deployment_policy`'s input and output, `get_deployment_policy`'s and
`list_deployment_policies`' outputs, the three journal outputs and `test_generate_deployment_grid`'s
input (a session start now accepts up to 288 times a day) move; `upsert_deployment_policy`'s are
removed.

## Contract history — v66.1 → v67

**Breaking at v67: four output fields removed.** v66.1 and v66.2 were additive and are recorded here
with it.

### Reshaped output — `get_agents_hub` (v67, breaking)

- **`summary` loses `messagesUsedToday`, `dailyLimit`, `messagesUsedPercent` and
  `avgCostPerMessageUsd`.** They reported the in-app arena chat's daily message quota, and that chat
  is retired, so every value would have read zero. A strict client that reads them fails to parse the
  summary. The rest of the summary is unchanged; per-agent spend stays on each row's `cost24hUsd`.

### Wider surface — two tools added (v66.2)

- **`scan_coin_agents`** (`coinTicker`; `mcp:read`) — one coin evaluated against every one of the
  caller's own agents, ranked in four arrays: `qualified` by score, `rejected` with the first failing
  gate, `unscorable`, and `ineligible` with the code `propose_entry_decision` would refuse the agent
  with (`AGENT_NOT_ACTIVE`, `MODEL_INACTIVE`, `AGENT_HALTED`). `rank` is global across the four.
  Rate-limited per user: a refusal is `RATE_LIMITED` with `retryAfterSeconds`.
- **`get_onboarding_requirements`** (no input; `mcp:read`) — the caller's readiness ladder: every
  rung, the spine counts and the next rung.

### Reshaped output — `get_strategy_draft` (v66.1)

- **`draft` gains `lastSource`** — the surface of the draft's latest write (`form`, `commander`,
  `telegram` or `mcp`), as the agent and radar draft reads already publish. Before v66.1 every read of
  an existing strategy draft failed its output check with `INTERNAL_ERROR`; it now succeeds.

### Vocabulary

`toolCount` goes 132 → 134. The added tools' schemas are new; `get_strategy_draft`'s and
`get_agents_hub`'s output schemas move. Descriptions that named the app's retired Agent Toolbox trade
tab now name Commander.

## Contract history — v66

**Breaking: one tool retired and one input reshaped.** Radar content now commits only as a draft the
player was shown a live preview of. The player's radar builder and every conversation share one
unsaved draft per coin.

### Removed tool — something you call no longer exists

- **`upsert_radar_deployment` is retired**, with no alias. Calling it is an unknown-tool error. To
  deploy or change a coin's Radar policy:
  1. `stage_radar_deployment_draft` the axes you are changing;
  2. `preview_radar_resolution` with `request: { kind: "DRAFT", draftVersion }` at the version staging
     returned;
  3. show the player the resolution, and on their word call `commit_radar_deployment_draft` with the
     `previewToken` that preview returned and `confirm: true`.

  Pausing is no longer a flag on the write: use `pause_radar_deployment`.

### Rejected input — something you send is no longer accepted

- **`preview_radar_resolution`'s `request` needs a `kind`.** `{ kind: "SLOTS", deploymentTimeframe,
  slots, simulatedRegime? }` is the old request. `{ kind: "DRAFT", draftVersion }` previews the
  player's draft of the coin at that version, composed over the deployed policy exactly as the commit
  writes it. `{ kind: "COMMITTED" }` previews the deployed policy a resume would arm. A request
  without `kind` is refused.

### Wider surface — seven tools added

- **`stage_radar_deployment_draft`**, **`get_radar_deployment_draft`**,
  **`list_radar_deployment_drafts`** and **`discard_radar_deployment_draft`** (`mcp:read`). The draft
  axes are `DEPLOYMENT_TIMEFRAME`, `RULES` (the complete ordered rule list, first = highest priority)
  and `DEFAULT_SLOT`, each written whole. None of them commits or arms anything.
- **`commit_radar_deployment_draft`** (`coinId`, `previewToken`, `confirm: true`; `mcp:wager`) — the
  only MCP committer of radar content.
- **`pause_radar_deployment`** (`coinId`; `mcp:wager`) — no revision and no certificate: a disarm never
  waits on a read.
- **`resume_radar_deployment`** (`coinId`, `previewToken`, `confirm: true`; `mcp:wager`) — needs the
  certificate a live `COMMITTED` preview returned.

### Reshaped output — `preview_radar_resolution`

- Gains **`previewToken`**, the certificate a LIVE preview (no `simulatedRegime`) of `DRAFT` or
  `COMMITTED` earns — null otherwise, and good for five minutes. It also gains
  **`enabledAfterCommit`**: whether the policy trades once the previewed write lands. A paused
  policy's draft stays paused.

### Refusals worth knowing before you commit or resume

- **The certificate is bound to what you previewed**: your credential, the player, the coin, the
  subject, the deployment and its revision, the draft version and the composed content. If the
  player edits the draft, another commit lands, the coin is redeployed, or an agent in it is deleted,
  the commit or resume is a `CONFLICT` and nothing is written. Preview again; never reuse the old
  certificate.
- **`commit_radar_deployment_draft` takes only a draft certificate**, and `resume_radar_deployment`
  only a committed-policy one.
- **`discard_radar_deployment_draft` with `confirm: true` requires `expectedVersion`**: the version the
  unconfirmed call named and the player was shown. A draft that moved since is answered again, and
  nothing is removed.
- **`delete_radar_deployment` also ends the player's radar draft of the coin.**

### Vocabulary

`toolCount` goes 126 → 132. `preview_radar_resolution`'s input and output schemas move, and
`upsert_radar_deployment`'s are removed.

## Contract history — v65

**Breaking on one input, and four refusals behind unchanged schemas.** Agent staging now names the
draft version its proposal was read against, and three commits and one stage step aside where they
would otherwise land around the player's open draft or on a strategy that moved.

### Rejected input — something you send is no longer accepted

- **`stage_agent_draft` requires `draftVersion`**, an integer ≥ 0: the `version` the
  `get_agent_draft` read you proposed against returned, or `0` when it returned `{ draft: null }`.
  A call that omits it, or sends a negative or fractional number, is refused at the boundary. On a
  new create draft send `0`, then reuse the `version` each accepted call returns.

### Refusals worth knowing before you stage or commit

- **A `draftVersion` above the draft's own is refused.** A draft's version only grows, so no read
  ever returned it: the draft you read was saved or discarded since. Read again — never raise the
  number to get past the refusal.

- **The contested-axis refusal is measured from your `draftVersion`**, not from a read the call
  makes for itself, so an edit the player made between your read and your stage is refused by name
  rather than silently overwritten.

- **`update_strategy_signal_rule` and `restore_strategy` answer `CONFLICT` while the player holds a
  draft of that strategy.** Compile the tune and stage it with `stage_strategy_plan`; for a restore,
  ask the player to save or discard their draft first. A caller whose player holds no draft is
  unaffected.

- **`stage_strategy_plan` refuses a plan the strategy committed past** after the plan compiled,
  with the same answer `apply_strategy_plan` gives: compile again against what is committed now.

- **Every refusal around an open draft names its version** in `details.draftVersion` — the
  committers above, the agent committers, and a stage refused for a version above the draft's own —
  so you can read the draft the player holds and propose against it.

### Changed meaning, unchanged shape

- **A draft axis's `source` names the door it came through.** A Telegram Commander turn stamps
  `telegram` and a connected client stamps `mcp`, where every staging call used to stamp
  `commander`. Both values were already in the enum.

- **A draft's `version` keeps counting after a save or discard empties it.** The next draft for the
  same agent or strategy starts above the last version rather than at `1`, so a version you read is
  never reissued to a different draft.

### Vocabulary

`toolCount` stays 126. One input schema moves, `stage_agent_draft`'s; no output schema moves.

## Contract history — v64.1

Purely additive in schema, with **one refusal behind an unchanged one.** An agent's unsaved
configuration now has a server home — the same owner-scoped *draft* strategies gained in v63.1 — so
the agent form, Telegram and a connected client all see one set of unsaved values. These tools are
how a conversation reaches it, and the two agent committers now step aside while it is open.

### Wider surface — four tools added

- **`stage_agent_draft({ request: { agentId?, axes } })`** writes proposed axes into the player's
  draft for an agent and commits **nothing**: the values become the agent's configuration only when
  the player saves. Unlike `stage_strategy_plan` it takes the axis *values* — `IDENTITY`,
  `BEHAVIOR`, `MODEL`, `TRADING_CONFIG`, and for an agent not yet created `STRATEGY_BINDING` — each
  written **whole**, so `BEHAVIOR` carries all three of risk, outlook and conviction and
  `TRADING_CONFIG` the complete agent-owned configuration. Axes you omit keep their values.
  Structure is checked on the way in; ranges are checked only when the player saves.

- **Omit `agentId` to open a create draft.** The server mints the id and the response carries it;
  name that id on every later call so one draft accumulates rather than a second opening beside it.

- **`get_agent_draft({ request: { agentId } })`** reads one draft, or answers `{ draft: null }` —
  not part-way through that agent is a value, not an error. `baseRevision` is `null` for an agent not
  yet created, and `baseMoved` reports an agent committed past the revision the draft was written
  against.

- **`list_agent_drafts({ request: { cursor? } })`** answers "what am I part-way through?" for a
  conversation holding no agent id, newest first. Offer to continue one of these before starting a
  second.

- **`discard_agent_draft({ request: { agentId, confirm } })`** destroys one draft on the player's
  word. Called with `confirm: false` it destroys nothing and is refused with when the draft was last
  written and which surface wrote it — tell the player that, then ask.

### Refusals worth knowing before you stage or save

- **A staged proposal refuses the axes the player typed after your call read the draft.** The
  refusal names the axes; read the draft again and propose against what they now have. Another
  surface's write to a *different* axis is retried once for you, then reported as a conflict.

- **`STRATEGY_BINDING` is refused on an agent that exists.** Rebinding replaces an agent's
  configuration and stays its own confirmed call, `rebind_intelligence_agent`.

- **`update_intelligence_agent` and `rebind_intelligence_agent` answer `CONFLICT` while the player
  holds a draft for that agent**, naming staging as the act available. Nothing about their input or
  output changed, and a caller whose player holds no draft is unaffected — this is the one change
  existing code can observe.

### Vocabulary

`toolCount` 122 → 126. No input or output schema of an existing tool moves.

## Contract history — v64

**Breaking, and it is a removal you will feel on two tools.** A vocabulary that never depended on
your draft was being re-serialized into every preview result; it now lives on discovery and the
preview names it.

### Reshaped output — `preview_strategy_report`

- **`conditionColumns` covers only the sections your report RENDERS.** It used to cover every
  section the server's header inventory holds, which meant every report-level scalar on every
  preview whether or not its module was placed — 36,652 bytes of payload, with each metric's gloss
  repeated once per scope (11 market-breadth sentences published as 99). If you read
  `session-field`, `market-breadth` or `reference-pairs` groups off a preview, they are gone.

- **Nothing stopped resolving.** The header inventory is unchanged, so a condition naming
  `mktBreadth_crypto` resolves exactly as it did. What changed is where you read its prose from.

- **`vocabularyDigest` is new** — the sha256 identity of the authoring catalog that render described
  its columns against, and the same digest a plan token binds as `authoringCatalogDigest`. Resolve
  the vocabulary once, cache it against this string, and re-resolve when it moves.

- **`structure.columns[].meaning` is gone.** It was a positional per-header string that neither
  serialization ever rendered — the glossary paragraph in `section.text` is built from
  `spanFragments`, and the structured arm always omitted `meaning`. `spanFragments` is unchanged.

- **`budgetUsage.estimatedTokens` now measures the whole served payload**, not the rendered module
  text inside it. Existing drafts read several times higher against the same cap. That is the fix,
  not a regression: the byte cap is measured on the serialized result and cannot be reported inside
  it, so this is the only meter that can warn you before a refusal — and it was counting about a
  tenth of what actually refuses.

### Wider output — `list_strategy_vocabulary`

- **`scalarFamilies` is where the vocabulary went.** Every report-level scalar family with its
  section, and one entry per METRIC carrying its gloss, legal condition operators, closed label
  vocabulary, read contract (`closesReadable` / `developingRead`) and the scopes it is measured at.
  One gloss per metric with its scopes named against it, never one per pair.

- **Served whole under every category.** A scalar describes the report, not a metric family, so it
  is not filtered by the `category` you asked for. The ambient session family carries
  `moduleKey: null` — it places no module and its operands are nameable all the same.

### Reshaped output — `compile_strategy_plan`

- **`reviewContext.columns` is gone.** It recompiled a full column contract per authored custom
  column — about 2,100 bytes for a five-header trajectory — describing columns the embedded
  `reportPreview.conditionColumns` already describes per rendered header. Ask
  `get_strategy_column_contract` when you want a column's exact normalized contract.

- **`approvedPlan.creationSeed` is gone.** It was the dense 84-rule scorecard *before* the
  overrides, published beside a `postState.signalRules` that is the same list *after* them and an
  `explicitRuleOverrides` naming exactly what differs. Apply never saw it either.

### Wider input acceptance

- **The section array and a custom section's column array lose their `maxItems: 64`.** That bound
  restated a configured cap of 32 as a looser 64 that never refused anything. `budgets.sections` and
  `budgets.sectionColumns` from discovery are the published values, and the server enforces them
  before it reads any market data.

### What to do

Call `list_strategy_vocabulary` once for the scalar vocabulary, key your cache on the preview's
`vocabularyDigest`, and drop any code that reads scalar groups off `conditionColumns`,
`structure.columns[].meaning`, `reviewContext.columns` or `approvedPlan.creationSeed`. A preview's
size now tracks the report and cohort you composed rather than the size of the platform's catalog.

## Contract history — v63.2

Purely additive again, and smaller: **one optional input field.** Drafts now cover a strategy that
does not exist yet, so a playbook you author in chat survives between compiles instead of starting
over each time.

### Wider input — one optional field on one tool

- **`compile_strategy_plan`'s CREATE arm gains an optional `strategyId`**, naming the create draft
  this compile continues. Omit it and the server mints an id exactly as it always has, so nothing
  you send today breaks.

- **Send it to accumulate; omit it to start fresh.** A first CREATE names nothing and mints; stage
  that plan and the draft opens at the minted id, which `list_strategy_drafts` and the plan itself
  both report. Name that id on the next CREATE and the second plan carries the same identity, so the
  one draft accumulates rather than a second opening beside it.

- **WHY YOU HAVE TO SAY IT.** The server will not choose for you. A compile knows only who you are —
  it has no conversation id — and you may hold several create drafts, so picking one could write this
  playbook's values over another's. A compile that names none mints a third id rather than guessing.

- **It names a draft; it does not choose an id.** It is accepted only as the identity of a create
  draft you own. An id naming no such draft is refused, as is one naming a draft for a strategy that
  already exists.

### Behaviour changes behind unchanged schemas

- **`stage_strategy_plan` admits a CREATE plan**, opening the create draft at the plan's own id when
  none exists yet. Its input is still `{ planToken }` alone, and the contested-axis refusal applies
  identically.

- **`list_strategy_drafts` rows gain `baseRevision`.** It is `null` for a create draft — a strategy
  that does not exist has no committed revision to be based on — and that null is the only thing that
  distinguishes one. There is no `kind`, `isNew` or `status` field beside it.

- **A create draft reserves nothing.** It consumes no quota and holds no name, so both are decided
  when the strategy is actually created. Holding drafts past your limit is legal; creating past it is
  not.

### Vocabulary

`axes.create` gains `strategyId`. No domain gains a value, and `toolCount` stays 122.

## Contract history — v63.1

Purely additive: **four new tools, nothing you send today changes.** A strategy now has an
owner-scoped *draft* — the unsaved authored values a player is part-way through — and these are how
an agent reads it, writes a compiled plan into it, and destroys one. The canonical record for every
contract move is `docs/architecture/MCP_CONTRACT_HISTORY.md` in `battlegrid-app`; the served version
is what the handshake announces.

### Wider surface — four tools added

- **`stage_strategy_plan({ request: { planToken } })`** writes a compiled plan's own changed axes
  into the owner's draft. It commits nothing and does **not** spend the plan, so the same token is
  still directly applicable afterwards. `planToken` is the only member the request accepts.

- **`get_strategy_draft({ request: { strategyId } })`** reads one draft, or answers `{ draft: null }`
  — "you are not part-way through this one" is a value, not an error. It reports `baseMoved` when the
  strategy has been committed past the revision the draft was written against.

- **`list_strategy_drafts({ request: { cursor? } })`** answers "what am I part-way through?" for a
  conversation holding no id. It includes a draft whose strategy you can no longer see, marked
  `strategyExists: false`, because that is exactly the work a player has lost track of.

- **`discard_strategy_draft({ request: { strategyId, confirm: true } })`** destroys one draft. The
  confirmation is required and unsaved values have no other copy; the strategy, its revisions and
  your other drafts are untouched.

### Refusals worth knowing before you stage

- **A staged plan refuses the axes you typed after it compiled.** `stage_strategy_plan` answers
  `CONFLICT` naming `contestedAxes`, the version the plan compiled over and the draft's own version
  per axis. Compile again so the plan absorbs those edits, then stage. A *previous staging* on the
  same axis is not a contest — only the owner's own hand is.

- **An apply refuses a plan whose draft has moved since.** Moved, discarded and
  already-committed-by-a-sibling are one answer with one recovery: compile again.

### Vocabulary

`toolCount` 118 → 122. No input or output schema of an existing tool moves — the draft version a
plan was compiled over rides inside the opaque plan token, which no schema declares.

## Contract history — v61

One part to read first: **the per-condition evidence clock is gone**, and what replaced it is not a
rename. Which bar a condition reads is now decided by the surface asking — a decision reads completed
strategy bars, a display read shows the forming one — and by each candle column's own Confirmed /
Developing selector. `closes` survives and changes meaning. The canonical record for every contract
move is `docs/architecture/MCP_CONTRACT_HISTORY.md` in `battlegrid-app`; the served version is what
the handshake announces.

### Rejected input — something you author is no longer accepted

- **A condition entry carrying `clock` is REFUSED** (61.0.0, `read-higher-timeframes-per-column`).
  The authoring schemas are `.strict()`, so `compile_strategy_plan`, `apply_strategy_plan`,
  `fork_strategy` and the HTTP save alike fail the body with the unknown-key error. There is no
  replacement key to send: the decision instant belongs to the caller, not to the condition. Two
  input hashes move, `compile_strategy_plan`'s and `preview_strategy_report`'s — the two tools that
  accept a condition entry.

- **`closes` stays mandatory and means something new.** It is now *held for N completed strategy
  bars* (1–5). Above `1` it is legal only over a header a completed bar actually moves, at or above
  the strategy timeframe, never over a developing read, and only where the condition carries a clause
  of its own — a referenced condition resolves once and contributes the same answer to every bar, so
  a hold reached only through a reference would count reads that never happened.

### Changed shape — what you receive moves

- **`ConditionOutcome.closeClock` becomes `hold`, and it is NON-NULL for every condition.** A
  one-close condition reads `0 or 1 of 1` rather than serving an absence, so a client no longer
  branches on whether the reading exists. The count is taken from completed strategy bars whatever
  basis the surface evaluated on.

- **`ReportConditionColumnDTO.closeClockReadable` becomes `closesReadable`, beside a new
  `developingRead`.** The first answers whether a condition addressing that header may hold more than
  one completed bar; the second states whether the header reads the bar still in progress at the
  decision instant. Both are server-supplied — derive neither. Eight output hashes move, covering
  every tool that serves a strategy's conditions or a report's addressable columns.

### Wider input — nothing you send today breaks

- **The `bars` selector is declared on eleven candle-series transforms, not four.** `value`,
  `classifyZone`, `classifyState`, `distance`, `spread`, `crossDetect` and `bandTouch` join
  `trajectory`, `aggregate`, `efficiency` and `maxShare`. It carries **no default value**, because
  the default is a rule rather than a constant: Confirmed (`"closed"`) above the strategy timeframe,
  and at or below it the series as the frame carries it. The resolved answer is served per column on
  `effectiveParameters.bars` — `null` where discovery has no anchor to resolve a rung against.

- **A higher-timeframe level is measured from the current strategy-bar price.** Distances and the
  candle label classifiers compare against the frame's current price rather than the higher-timeframe
  bar's own close, which could be a full higher-timeframe bar stale. A distance chained into a series
  keeps every slot on its own bar's close.

### Vocabulary

`domains.conditionClock` is removed; `domains.columnBars: ["all", "closed"]` takes its place, and
`axes.condition` loses `clock`. `toolCount` stays 117.

## Contract history — v60

One release train, four numbers, and two parts to read first: **`propose_entry_decision` no longer
decides in the call**, and **a closed trade's `tradeStatus` now follows its net P&L**. v55 through v59 are not written up here; the canonical record for
every contract move is `docs/architecture/MCP_CONTRACT_HISTORY.md` in `battlegrid-app`, and the
served version is what the handshake announces.

### Rejected input — something you author is no longer accepted

- **The entry axis names no bar but the strategy's own, and three of its keys are gone** (60.0.0,
  `decide-entry-on-strategy-close`). The authoring schemas are `.strict()`, so `compile_strategy_plan`,
  `apply_strategy_plan`, `fork_strategy` and `restore_strategy` REFUSE a body carrying
  `entry.confirmTf` (the deciding bar is the strategy's own timeframe, so a required input whose only
  legal value was another field of the same strategy is absent rather than mirrored), `entry.closes`
  and `entry.bandAtrMultiple` (a multi-bar hold is declared on the condition that needs it; the
  displacement band is replaced by the platform's own entry-deviation gate, measured against the
  decided close), and the whole `exit` object (the open-position exit lane judges one closed candle
  of the position's strategy timeframe). Exactly one input hash moves, `compile_strategy_plan`'s.

- **`entry.trigger: AT_SIGNAL` is retired for authoring** (60.0.0, `retire-at-signal-trigger`, riding
  the same number). Refused on every authoring surface and on a RESTORE, which rebuilds a stored
  revision through the same value object. The enum member stays READABLE on every strategy read and
  on a fired decision's provenance, so a pre-retirement revision is still legible — it just cannot be
  re-authored. The three that remain are `ON_CANDLE_CLOSE`, `STOP_THROUGH_LEVEL` and `ON_RETEST`.

### Changed meaning, unchanged shape

- **`tradeStatus` follows NET P&L on every closed trade** (60.3.0, `label-trade-outcome-by-pnl`).
  No schema hash moves for this and the values you receive change anyway. It used to map the close
  REASON onto a verdict — every `TAKE_PROFIT` was `WON`, every `STOP_LOSS` was `LOST`, and a
  `MARKET_CLOSE` of either sign was the neutral `CLOSED` — so a stop that filled after a break-even
  reprice was reported as a loss and a take-profit eaten by fees as a win. `LIQUIDATED` still
  outranks the number, because a force-close is not a verdict about the trade; `CLOSED` now means
  only that there is no outcome row to judge. A client that counted `WON` rows was counting
  take-profits.

- **A proposal is QUEUED, not decided** (60.2.0, `queue-manual-entry-for-close`). This is the entry in
  this section to act on. `propose_entry_decision` registers a request against the agent's next
  strategy-bar close and returns immediately: no model runs, nothing is spent, and the call carries
  `type: "queued"` with `request` — `requestId`, the bar (`barStart`), when the answer is due
  (`decidesBy`) and the last instant that bar may still be decided (`windowEndsAt`). The answer
  arrives later, in the agent's conversation and, when it proposes a trade, in
  `list_pending_approvals`. A close that does not qualify, a window that passes with no sweep, and a
  bar the agent's own radar deployment decided first are each recorded in the conversation instead.

  `recommendation` and `no_trade` REMAIN in the union: the idempotency registrar replays results
  recorded before this release for their TTL, so a client that dropped those members would fail on
  its own retry. **A client written against 60.2 handles `queued` and `error`**; one that must also
  replay handles all four.

### Reshaped output — the same call returns a different shape

- **The four retired entry keys leave every strategy read** (60.0.0) — `get_strategy`,
  `list_strategies`, `fork_strategy` and both plan envelopes — and `confirmTimeframesByMainCandle`
  leaves `list_strategy_vocabulary`, because there is no confirm set left to publish.

- **`entryDiscipline.closes` and `.bandAtrMultiple` go `number` → `number | null`** (60.0.0) on
  `get_trade_outcome_by_decision` and `list_trade_outcomes`. Null on every decision fired after this
  release, which authors neither; a non-null pair dates the row to the arming era.

### Widened enum — new members your own copy rejects

- **`TradeConvErrorCode` gains `REQUEST_PENDING`** (60.2.0), on the SURFACE arm of
  `propose_entry_decision`'s error: one pending request per user and coin, so a second is refused. A
  coin already carrying a pending or live position is refused before anything is queued, as an
  ENGINE-origin `OPEN_POSITION_CONFLICT` — a member that was already published, reaching this surface
  for the first time.

### Additive in the same span

- **The exit names the leg that filled** (60.3.0, `label-trade-outcome-by-pnl`). `TradeOutcomeDTO`
  and the pipeline outcome summary gain `exitRepriceSource`: the reprice that placed the protection
  leg which actually closed the position — `BREAK_EVEN`, `TRAILING`, `TIME_DECAY`,
  `MANUAL_OVERRIDE`, `UPDATED` — or null on every close no protection leg filled and on a leg that
  was never repriced. It is the mechanism behind the verdict above, so read it beside `closeReason`
  before calling a stopped-out trade a failure. The enum is the one `get_position_audit_history`
  already publishes. Four output hashes move; no input schema does.

- **Two tools join the catalog for the request lifecycle** (60.2.0, `queue-manual-entry-for-close`).
  `get_entry_request` (read scope) reads a request that is still pending and is `NOT_FOUND` once it
  has been answered, cancelled or expired — the answer is in the conversation, not there.
  `cancel_entry_request` (`mcp:wager`) withdraws one before its bar is decided. `toolCount` 115 → 117.

- **Three radar reads publish the close decision** (60.1.0, `add-radar-close-decision-state`).
  `get_radar_deployment`, `list_radar_deployments` and `preview_radar_resolution` carry one further
  key on `resolvesNow`: `closeDecision`, non-null whenever an agent is on duty for the pair. It
  carries the deciding `timeframe`, the `nextCloseAt` instant, a `state` of `WAITING` or `DEFERRED`
  (a bar has closed and no sweep has decided it yet), and `last` — the bar, the instant, the outcome
  (`FIRED` / `NOT_QUALIFIED` / `MISSED`), the gate and the closed reading's score against its
  minimum — or null before the pair's first close decision.

  **Read it first on a close-deciding pair.** The sibling `qualified` and `qualificationBlock` fields
  are the per-minute DISPLAY reading there and decide nothing, so an agent ranking them reports
  "qualified — watching for a setup" about a pair whose last three closes were each refused. Neither
  field is removed or reshaped. A `NOT_QUALIFIED` outcome with a NULL gate beside a score at or above
  the minimum is the consumed edge — the close qualified and the baseline was already spent — stated
  by the server so no client compares the two numbers.

## Contract history — v37 → v54

Eleven majors reached authors while this section stopped at v36. That gap is the mechanism, not an
oversight: since v31 a contract move needs no release here, so nothing forced a note to be written —
and the documentation ships inside the tarball, so a note written but unpublished reaches nobody.
Both halves are now closed by a rule keyed to the *served* contract rather than to a release of this
package.

**50.0.0 and 51.0.0 arrived late, and the reason is worth naming.** The re-vendoring errand that
used to carry these notes is now a generated export
(`battlegrid-app/server/scripts/export-mcp-skills.mjs`), and it owns three paths — `skills/`,
`skills/EXPORT.json`, and the vendored digest. It deliberately does not touch this file. So the
digest kept arriving on time while the note stopped travelling with it, and this section sat at
v49.5 against a served contract of 51.0.0. Nothing a client could observe was wrong; what was
missing was the sentence telling them so. **A contract move still needs a human-authored entry
here, and the export lane will not remind you.**

### Accepted again — input that was rejected now compiles

Nothing to migrate. This is the one direction that cannot break a client: a body the server used to
refuse is now stored. Listed because a client that special-cased the refusal can delete that branch.

- **A signal rule patch no longer forces you to restate `allocation` and `required`** (51.0.0,
  `fix-signal-rule-patch-semantics`). On `update_strategy_signal_rule`, and on the `rules` element
  of `compile_strategy_plan`, both fields become optional and join `params` under ONE omission
  rule: **an omitted mutable field preserves the stored value for that signal.** "Raise this
  signal's weight" is now expressible.

  ```jsonc
  { "strategyId": "…", "expectedRevision": 7, "signalId": "volume_surge",
    "allocation": 3 }               // `required` and `params` keep exactly what is stored
  ```

  **Every existing client keeps working** — a complete payload is still a valid patch — so this is
  listed for what you can now STOP sending. Before it, both fields were mandatory on every rule
  surface, so a caller that had not first read the current rule had to invent a value it was never
  asked about. That is not hypothetical: revision 5 of a production strategy flipped `required`
  false → true unasked while moving a weight 2 → 3, turning a scoring signal into a **mandatory
  gate** — which changes whether the agent takes trades at all.

  One boundary on the newly legal ground, and it breaks nothing: a patch carrying **no** mutable
  field is refused — *"A rule patch must change something: supply at least one of allocation,
  required or params."* — rather than minting a no-op revision. Under 50.0.0 that request could not
  be formed at all, so nothing that used to work is now refused.

- **An arming trigger no longer constrains its required conditions' clock** (49.4.0,
  `restore-arming-trigger-authoring`). `compile_strategy_plan`, `apply_strategy_plan` and
  `fork_strategy` accept a strategy whose entry trigger is `ON_CANDLE_CLOSE`, `STOP_THROUGH_LEVEL`
  or `ON_RETEST` **while a required condition reads the `LIVE` clock**. v48.1 announced that pairing
  as rejected, naming the offending condition key on `VALIDATION_ERROR`; that refusal is gone.

  Why it was withdrawn, since the reasoning matters more than the rule: it ran against the whole
  assembled strategy, so it refused *every* edit to a strategy in that shape — a rename, one report
  column, one signal weight — plus restore and fork. For a strategy whose required conditions read
  columns that can never carry a `CLOSE` clock (zone distances, perp/spot flow), there was no legal
  shape to move to at all. And its premise — that the pairing can never fire — was measured before
  the arming lifecycle was corrected, and no longer holds.

  The underlying question, *should an entry that waits for a close be decided on a forming bar*, is
  now settled where the decision is made rather than by refusing the author's declaration.

### Changed meaning, unchanged shape

- **`blocksScanGate` reads `true` for a class it did not** (50.0.0, `own-scan-served-set-once`), on
  `preview_radar_resolution`. Blocking is now derived from the lane's **served set** rather than
  switched over the reach reason, so a `FEED`-reason refusal on an operand no reader in the lane
  serves BLOCKS instead of deferring. No field changes shape, and a client that already renders the
  key renders the new answer — but a client that treated `blocksScanGate: false` as "this will
  resolve once data arrives" now sees a deployment that will not fire. Nothing in the payload tells
  you this moved.

- **Three tools serve different values for identical input** (47.3.0, `derive-scan-fetch-from-report`).
  The radar scan leg now derives its timeframe fetch from the strategy's **report** rather than the
  on-duty agent's three perception rungs, so a required condition addressing an absolute timeframe
  outside those rungs — never evaluated at scan before — now is. No field moves; the values do:

  | Tool | What moves |
  |---|---|
  | `preview_radar_resolution` | `conditionReach[].reachReason` goes `AGENT_TIMEFRAME` → `null` |
  | `get_radar_activity` | `scanReachReason` moves the same way, **on new rows only** — rows already written keep what they were recorded with |
  | `get_agent_coin_qualification` | `reachReason` moves the same way; its sibling `verdict` moves `UNMEASURABLE` → a decided verdict |

  **Read the last one carefully:** a client treating `UNMEASURABLE` as "this gate is switched off"
  will now see that gate **BLOCK**. `AGENT_TIMEFRAME` keeps its member and narrows to the one cause
  no fetch can discharge. Nothing in the payload tells you this moved.

### Rejected input — something you author is no longer accepted

- **A condition naming a `swingHi` / `swingLo` header is refused — the indicator is a Donchian
  channel** (53.0.0, `rename-donchian-channel`). The rolling-window extremes indicator computed the
  highest high and lowest low of the trailing 20 closed bars — a Donchian channel — under a swing
  point's name, and the name asserted a property the value does not have (a swing high survives being
  broken; a channel edge re-anchors the instant it is). Every layer of the vocabulary moves at once,
  with no alias: a clause on `compile_strategy_plan`, `apply_strategy_plan` or `fork_strategy` naming
  a header on the old stems — `dist_swingLo`, `dist_swingHi_4h`, `dist_swingLo_rank_near`, any
  timeframe- or rank-suffixed form — is refused as `CONDITION_COLUMN_UNKNOWN` where 52.0.0 accepted
  it. The same shapes exist on the `donchianHi` / `donchianLo` stems, which
  `get_strategy_column_contract` lists with the labels *20-bar high* / *20-bar low*. Every value is
  the same number under its new name.

  **Rename the stems** (`swingHi` → `donchianHi`, `swingLo` → `donchianLo`, suffixes unchanged) in
  every condition and Market Read marker you author. That is the whole migration for what you send.

- **`entry.levelSource` is refused — the level is derived, never authored** (52.0.0,
  `derive-entry-level`). The strict `entry` object on `compile_strategy_plan`, `apply_strategy_plan`
  and `fork_strategy` is six keys — `trigger`, `confirmTf`, `closes`, `bandAtrMultiple`,
  `levelOffsetAtrMultiple`, `validForBars` — and a body carrying `levelSource` is refused naming
  the key where 51.0.0 accepted it. `STOP_THROUGH_LEVEL` rests a stop past the swing channel's
  CURRENT edge in the trade's direction (the 20-bar high for a long, the 20-bar low for a short);
  `ON_RETEST` rests a limit in front of the edge a close most recently BROKE. Your two dials are the
  unsigned distance from that edge (`levelOffsetAtrMultiple`, 0–2 ATR; a long adds, a short
  subtracts) and the bar validity (`validForBars`, 1–24). A meaningful `validForBars` under
  `AT_SIGNAL` or `ON_CANDLE_CLOSE` is now refused as `PARAMETER_NOT_HONOURED` like the other level
  dials.

  **Drop the key.** That is the whole migration for what you send; the six-key example below is
  the current shape.

- **`update_strategy_signal_rule` requires `confirm: true` when the strategy has bound agents**
  (51.0.0, `fix-signal-rule-patch-semantics`). The write re-materializes scoring configuration onto
  every bound agent immediately — including agents holding open USDC positions — and until now
  nothing on the server asked. A rule edit on a strategy with one or more bound agents is refused
  without the flag, and the message names the count. An edit on a strategy with **nothing bound is
  unaffected**, and so is every path through the web editor.

  **Send `confirm: true`.** That is the whole migration, and `confirm` is published on the input
  schema — but nothing in the schema says WHEN it becomes mandatory, because the condition is the
  bound-agent count rather than the shape of your body. The refusal rides the existing
  `VALIDATION_ERROR` code, so a client that omits it discovers the rule at the refusal.

  Why it moved to the server: the guard existed, but only as served prose the calling model could
  decline — and did, twice in production on 2026-08-24. `archive_strategy` and
  `rebind_intelligence_agent` have taken a server-enforced `confirm` all along; single-rule tuning
  was the outlier among its own siblings, and it is the one that writes to scoring.

- **A radar deployment is refused when its strategy reads a session-field scalar** (50.0.0,
  `own-scan-served-set-once`). `upsert_radar_deployment` refuses a deployment whose slot agents'
  bound strategy carries a condition reading one of five SESSION-FIELD scalars — `fieldPlayers`,
  `fieldUpBias`, `fieldBiasDir`, `captConc`, `picksSpread` — with
  `CONDITION_OPERAND_UNSERVED_IN_LANE`. A body accepted under 49.5.0 is refused under 50.0.0
  without one byte of it changing.

  **Why a refusal and not a warning.** Those five describe a game SESSION, and radar runs outside a
  session at BOTH its stages — so such a condition can never resolve there. The deployment formed
  no fire edge and the agent did nothing on that coin, silently, forever. The refusal converts a
  permanent silence into an error at the moment you author it.

  **Migrate** by moving the clause to a scalar radar reads — the Market Breadth or Reference Pairs
  families, which are market-wide reads with no session dimension — or by binding the strategy to
  an arena agent instead. The error carries both halves: `allowedDomain` enumerates every servable
  header, and the message names the sections.

  **Strategy authoring is untouched by this bump.** The same strategy is legal, and reads those
  scalars correctly, on an arena agent — which is why the refusal is on the DEPLOYMENT and not on
  `compile_strategy_plan` / `apply_strategy_plan`.

- **A benchmark-bound section no longer accepts crowd metrics or rank transforms** (49.0.0,
  `fix-benchmark-legality-save-path`). On a custom section carrying a non-null `benchmarkTicker`, a
  column whose metric is enrichment-stage (the `CROWD_*` family, `FLOW_ALIGN`, `SMART_RETAIL`,
  `CAPTAIN_CONF`, `CONFIDENCE`, `SETTLED_AT`, the `PERP_SPOT_*` trio) is refused with
  `REPORT_COLUMN_BENCHMARK_METRIC_UNSUPPORTED`, and one carrying a `rank` transform in either the
  direct or the chained position with `REPORT_COLUMN_BENCHMARK_TRANSFORM_UNSUPPORTED`. Every tool
  accepting a section array is affected, and no other byte of your body changes.

  **Fix it by moving the column, not by retrying.** Both readings are defined *relative to the
  cohort being evaluated* — a crowd reading is what this session's players did, a rank is a position
  among the coins under evaluation — and a benchmark is deliberately outside that cohort. Neither
  has a value there, which is why such a column could never render. Put it on an ordinary section
  (`benchmarkTicker: null`), or drop it.

  **This is a fix, not a new rule.** The restriction shipped with benchmark sections and was already
  enforced by the column builder, by report materialization, and by `get_strategy_column_contract` —
  so an author who checked a column against discovery first has been seeing this refusal all along.
  What changed is that the SAVE path now asks the same question. Previously it did not, so such a
  section persisted and then failed at every evaluation instead of at authoring.

- **A custom report section no longer accepts `timeframe`** (48.0.0,
  `remove-section-anchor-override`). The per-section anchor override is gone. Every tool accepting a
  section array — `compile_strategy_plan`, `preview_strategy_report`, `derive_strategy_rule_view`,
  and `apply_strategy_plan` — refuses a section carrying it: `sections[N]: Unrecognized key(s)`,
  with no other byte of your body changing. Drop the key. A section's columns resolve against the
  **strategy** timeframe, and a column reaches any other timeframe by pinning it on the column
  (`timeframe: { abs: '4h' }`) — which it could always do. Relative column references
  (`anchor`/`lower`/`regime`) are untouched, and are the point: they track the strategy.

- **A custom report section requires `notes`** (43.0.0, `add-authored-section-notes`). Every tool
  accepting a section array — `compile_strategy_plan`, `preview_strategy_report`,
  `derive_strategy_rule_view` — refuses a section without it: `sections[N].notes: Required`, with no
  byte of your body changing. Send explicit `null` for "no note". It is required rather than optional
  because these payloads are a FULL REPLACE: an omitted key and an explicit `null` would be the same
  request on the wire, so every rebuild site would silently clear a note its author wrote.
  `benchmarkTicker` carries the same required-nullable discipline for the same reason.

- **Every condition requires `clock` and `closes`** (44.0.0, `add-condition-clock`), and **`exit`**
  arrives with them. A condition entry now carries eight keys, not five. `clock` is `"LIVE"` (the
  previous behaviour — the forming bar) or `"CLOSE"` (settled bars); `closes` is how many consecutive
  closed bars must read true, 1–5, and is always `1` under LIVE. No wire default, for the same
  whole-set-replacement reason as `notes`: a defaulted key would let an unrelated re-save silently
  un-clock an enforced money gate back to forming-bar evidence.

  A `CLOSE` clock is accepted only where a closed frame can change the reading — the clause must
  resolve from the coin's own candle series at offset 0. Frame-inert operands (perp-payload scalars,
  published rolling changes, ranks, zone entities, regime labels, enrichment metrics, session
  scalars) are refused with `CONDITION_CLOCK_OPERAND_ILLEGAL` naming the header and the remedy: split
  that clause into its own LIVE condition and `conditionRef` it. `exit: true` is legal only under
  `clock: "CLOSE"` — an exit fired on a forming bar is an intrabar exit.

- **A strategy requires a six-key `entry` object** (44.0.0 for four keys, 47.0.0 for three more —
  `add-entry-on-close`, `add-level-trigger-execution` — and 52.0.0 removed `levelSource`,
  `derive-entry-level`). Required on every CREATE, on `compile_strategy_plan`, `apply_strategy_plan`
  and `update_intelligence_agent`. A client sending 44.0.0's four-key object is refused with
  `entry.levelOffsetAtrMultiple: Required` without one byte of it changing.

  ```jsonc
  "entry": {
    "trigger": "AT_SIGNAL",          // | ON_CANDLE_CLOSE | STOP_THROUGH_LEVEL | ON_RETEST
    "confirmTf": "4h",               // the strategy timeframe or the rung below it — nothing else
    "closes": 1,                     // 1–5; must be 1 unless ON_CANDLE_CLOSE
    "bandAtrMultiple": 1.0,          // > 0, and <= the platform's entry-deviation gate
    "levelOffsetAtrMultiple": 0,     // 0–2, UNSIGNED distance from the derived edge; 0 unless a level trigger
    "validForBars": 4                // 1–24 of the strategy's own bars; 4 unless a level trigger
  }
  ```

  `AT_SIGNAL` with those values is byte-identical to pre-44 behaviour. The legality matrix runs one
  way: all six keys are always present, so a MEANINGFUL value under a trigger that ignores it is
  refused rather than accepted-and-dropped — a dial never silently does nothing.

- **A `strategyTimeframe` the platform does not ingest is refused** (39.0.0,
  `move-renderer-to-rendered-section`) on `get_coin_market_context`, where it was previously
  accepted. The number is the only signal a client gets.

- **`eventType` gains `ENTRY_EXPIRED_UNCONFIRMED`** (48.1.0, `fix-entry-arming-lifecycle`) on
  `get_radar_activity` and `get_radar_activity_summary` — an armed entry that reached its episode
  lifetime without ever receiving a confirming close. A client holding its own closed copy of that
  enum rejects the new member; one that switches exhaustively on it needs the branch.

  `entryVoidCause` is deliberately **unchanged** and still carries exactly `BAND`, `CONDITIONS`,
  `STRUCTURAL`. Each of those is a measurement taken *at* a close, so an episode that reached no
  close gets its own event type rather than a fourth cause — `ENTRY_VOIDED` means "called off at the
  close", a claim this outcome must not make.

  The authoring boundary also gains a refusal with **no schema change**: a strategy declaring an
  arming trigger whose `required` conditions read the `LIVE` clock is rejected on the existing
  `VALIDATION_ERROR` code, naming the offending condition key. Same code, same shape, new reason —
  so nothing in the published schema tells you it can now happen.

### Removed — no alias exists

- **`SWING_LOW` / `SWING_HIGH` leave the stop and take-profit method enums, and the four S/R
  indicator keys leave the signal vocabulary** (53.0.0, `rename-donchian-channel`). `DONCHIAN_LOWER`
  / `DONCHIAN_UPPER` and `donchian_upper` / `donchian_lower` / `prev_donchian_upper` /
  `prev_donchian_lower` carry the same values; nothing answers to the old names.

- **`get_coin_market_context` is REMOVED** (40.0.0, `retire-get-coin-market-context`). Calling it
  returns an unknown-tool error. There is deliberately **no alias**: a silent redirect would hide a
  payload shape change from a client that never asked for one. Use `get_market_context`.

- **`get_macd_heatmap` leaves the published surface** (41.0.0). Same shape of break, same absence of
  an alias.

- **`isPrimary` is removed from every published `EvaluatedSignal`** (38.0.0) — `get_signal_log`,
  `get_public_agent_signal_log_detail`, and every other tool returning a signal scorecard. Reading it
  now finds the key absent rather than false.

### Reshaped output — the same call returns a different shape

- **`scan_agent_coins` returns the ranking, not an explanation of every coin** (54.0.0,
  `fix-mcp-scan-row-altitude`). The scan used to wrap the app's full per-coin qualification verdict
  in every row — two directions with candidate-level construction and a stop-loss policy band, four
  gates each with its own measurement, condition reach reasons, the ATR corridor. For a 78-coin
  catalog that was **82,147 characters, ~1,053 per row**, which is past the tool-result cap of every
  client we know of: the calling model received a file path instead of an answer, so the tool did not
  deliver its result even when the scan succeeded.

  `rows` is gone. Three ranked arrays replace it, and `rank` is **global across all three**, so
  reading them in this order reproduces the server's own sequence:

  ```
  qualified[]   { rank, coinTicker, scorePercent, coinDataStopped }
  rejected[]    { rank, coinTicker, scorePercent, firstFailReason, scoreShortfallPercent }
  unscorable[]  { rank, coinTicker, coinDataStopped }
  ```

  Array membership now carries what the row `kind` discriminator and the `qualifies` flag used to,
  and both are gone with them; `firstFailReason` is non-nullable on a rejected row, because a
  non-qualifying verdict always names the gate that blocked it. Rows also lose `long`, `short`,
  `gates`, `tradeableAtrRange`, `evaluatedAt`, `coinName`, `assetClass` and `category`.

  Two things are new. The agent's `agentId`, `agentName`, `strategyTimeframe` and `minScorePercent`
  move to an `agent` object carried **once** instead of on all 78 rows — `null` when the scan scored
  nothing at all. And `scoreShortfallPercent` is server-computed: how far below the minimum the score
  fell, non-null exactly when the aggregate score is what blocked, so you never subtract a published
  threshold from a published reading yourself.

  `scanStartedAt`, `coinsScanned` and `qualifiedCount` are unchanged, and so is every coin: same
  rows, same order, same ranks, same verdicts, same rate buckets, same evaluator. Only the fields
  moved — about 8.8 KB for the same 78-coin scan.

  **Migration.** Read `qualified` / `rejected` / `unscorable` instead of `rows`, and take the agent's
  thresholds from `agent` rather than from the first row. For a shortlisted coin's full per-direction
  and per-gate detail, call `get_agent_coin_qualification` on up to 12 tickers — it carries every
  dropped field and costs no second scan (the scan is rate-limited to 3 per agent and 10 per user a
  minute; the probe is not).


- **Report headers, glosses and signal indicator keys are renamed for the Donchian channel**
  (53.0.0, `rename-donchian-channel`). Every report surface — `preview_strategy_report`,
  `get_strategy_section_template`, the agent prompt previews — renders `donchianHi` / `donchianLo`
  and their `dist_…` / `…_rank_near` forms where it rendered `swingHi` / `swingLo`, with the labels
  *20-bar high* / *20-bar low* and glosses that say what the number is (the highest high / lowest low
  of the last 20 closed bars — the channel's edges). Signal definitions
  (`get_strategy_signal_definition`, `list_strategy_signals`) and signal-log `indicatorValues`
  carry `donchian_upper` / `donchian_lower` / `prev_donchian_upper` / `prev_donchian_lower` for the
  four S/R signals. Signal ids, the `SUPPORT_RESISTANCE` module and its display names are unchanged.

  **Read the new keys.** A reader keyed on `swing_high` / `swing_low` finds nothing; the values are
  the same numbers under the new keys.

- **The stored entry discipline no longer names a level source** (52.0.0, `derive-entry-level`).
  `StrategyDTO.entry` (`get_strategy`, `list_strategies`, the `fork_strategy` / `archive_strategy` /
  `restore_strategy` envelopes) and the apply envelope's `postState.entry` lose `levelSource`.
  `TradeOutcomeDTO.entryDiscipline` (`get_trade_outcome_by_decision`, `list_trade_outcomes`) loses
  it and gains `levelOffsetAtrMultiple: number | null` — the offset in force at the fire, `null` on
  rows written before its column existed. A reader that rendered the level source renders the
  geometry from `trigger` and the outcome's `direction` instead: which edge and which order shape
  are a pure function of those two fields, so no stored copy is served.

- **`update_strategy_signal_rule` gains its own response envelope** (51.0.0,
  `fix-signal-rule-patch-semantics`). It no longer shares `{ strategy }` with its siblings. The
  response is `{ strategy, ruleChanges }`, where `ruleChanges` is the server's own before/after
  pair for the edited signal — `[{ signalId, before, after }]`, each side a full rule object. It is
  `null` when the mutation changed no rule, **never `[]`**.

  **Report the change from that pair, not from memory.** The planner always computed the diff and
  the tool discarded it, so a caller narrating what it just did had only its own recollection of
  the before-value. One production edit shipped a wrong receipt on top of a wrong write that way,
  and the write was unreconstructable from the audit trail afterwards.

  Additive, but published on a `.strict()` shape — a decoder pinned to the old two-key object
  rejects the new key. `fork_strategy`, `archive_strategy` and `restore_strategy` keep the shared
  `StrategyResponseSchema` and publish exactly what they did; it was deliberately NOT widened for
  them, so this reshape reaches one tool only.

- **An entry void now names the gate that refused it** (49.5.0,
  `fix-arming-trigger-clock-authority`), on `get_radar_activity_summary`. In the cause rollup, the
  `ENTRY_VOID` group's `gateCode` widens from always-`null` to `QualificationGateCode | null`: a
  conditions-side void carries the gate that blocked — `AGGREGATE_BELOW_MIN`,
  `REQUIRED_COUNT_BELOW_MIN`, `REQUIRED_CONDITION_FALSE` — while a band void stays `null`, because
  that void happens on a reading that qualified and has no failing gate to name.

  A client that renders the field through the same enum the response already uses on four other
  cause arms needs no change. One that treated it as a literal `null` — a strict decoder pinning the
  type, or a branch keyed to its absence — sees a value it did not expect. That is the whole
  migration.

  Why it moved: the group previously collapsed every conditions-side void under one label. The
  first 26 in production carried that label while two different gates had produced them, and none of
  them was a required condition being false. The rollup ships counts rather than rows, so the gate
  could not be recovered client-side.

- **The normalized report section loses `timeframe`** (48.0.0, `remove-section-anchor-override`), on
  every tool that publishes a strategy: `get_strategy`, `fork_strategy`, `archive_strategy`,
  `restore_strategy`, `update_strategy_signal_rule`, `apply_strategy_plan`, and
  `compile_strategy_plan`'s post-state. A strict parser rejects the shorter object; a lenient one
  reads a section whose anchor is the strategy timeframe, which it now always is.

- **`get_strategy_column_contract` renames its anchor, both ways** (48.0.0). The request field
  `sectionTimeframe` becomes `anchorTimeframe` — same meaning, and still optional. On the response,
  `timeframe.requiresSectionTimeframe` becomes `requiresAnchorTimeframe`, and
  `timeframe.sectionTimeframeOverrideAllowed` is **removed**: it published whether a column could go
  in an anchor-overridden section, and no section can be overridden. One call also stops being
  refused — a timeframe-inert metric supplied with an anchor now compiles.
  `REPORT_COLUMN_SECTION_TIMEFRAME_UNSUPPORTED` leaves the `authoringCode` vocabulary.

- **`RenderedSection.notes` stops carrying provenance** (42.0.0, `separate-section-facts-from-read`),
  on `preview_strategy_report`, `compile_strategy_plan` and `get_market_context`. A new REQUIRED
  `provenance: string[]` carries it instead. A client reading provenance out of `notes` now reads an
  author's prose, or nothing — which is a silent misread, not an error.

- **`preview_strategy_report.renderedSections[]` gains `authoredNote`** (43.0.0), the author's read
  for a custom row and `null` on a platform row. Additive, but published on a `.strict()` shape.

- **`get_radar_activity` serves its evaluation curve on the FIRST PAGE ONLY** (37.0.0), and
  `get_radar_activity_summary` is added. A client reading the curve off a later page finds it absent.

### Widened enum — new members your own copy rejects

- **The stop and take-profit method enums gain `DONCHIAN_LOWER` / `DONCHIAN_UPPER`** (53.0.0,
  `rename-donchian-channel`), replacing `SWING_LOW` / `SWING_HIGH` on the signal-pipeline detail
  schemas — trade-setup options, R:R-rejected pairs and candidate levels. A copy of either enum that
  rejects unknown members must add the two new ones; the two old ones never appear again.

- **`TradeExecutionFailureReason` gains `LEVEL_NOT_RESTABLE`** (52.0.0, `derive-entry-level`): a
  level entry refused at placement because its resting price sat on the wrong side of the exchange
  mid — a buy stop at or below it, a buy limit at or above it, and the mirror for a sell. It appears
  on the `list_signal_logs` failure-reason filter input and on the signal-pipeline execution
  summary's `failureReason`, with origin `CLIENT_GATE`. There is no distance limit: a level far from
  the mark rests until its bar validity expires.

- **The authorable metric vocabulary widens by 29 keys** (46.1.0, `add-indicator-catalog-coverage`):
  Keltner (`KC_UPPER`/`KC_MID`/`KC_LOWER`), Supertrend (`ST_LINE`/`ST_DIR`), Hull (`HMA20`),
  WaveTrend (`WT1`/`WT2`), QQE (`QQE_RSI_MA`/`QQE_STOP`), Parabolic SAR (`PSAR`), Ichimoku
  (`ICHI_CONV`/`ICHI_BASE`/`ICHI_SPAN_A`/`ICHI_SPAN_B`/`ICHI_LAG`), Williams %R (`WILLR14`),
  Stochastic RSI (`STOCH_RSI14`), session pivots (`PIVOT_P`/`PIVOT_R1`–`R3`/`PIVOT_S1`–`S3`), plus
  four already-published fields that became addressable: `BB_UPPER`, `BB_LOWER`, `DI_PLUS`,
  `DI_MINUS`.

  Additive on the wire — every request you can send today is still accepted. It is called out here
  because a client holding its own closed copy of the metric enum rejects the new members, and
  because **an agent holding a cached belief that these are inexpressible will substitute for a
  primitive the platform now serves**. That failure raises no error at all: the author is simply told
  a strategy cannot be built.

- **`EntryTrigger` gains `STOP_THROUGH_LEVEL` and `ON_RETEST`, and `EntryLevelSource` is published
  for the first time** with four members (47.0.0). Additive on their own; the required keys above are
  what make that bump a major.

- **Each signal-checklist item's `measured` object gains a required `triggered` boolean** (47.2.0,
  `fix-entry-prompt-signal-evidence`) on `get_signal_log` and `get_public_agent_signal_log_detail`,
  on the `numeric` and `categorical` arms. Additive and MINOR — a client ignoring it is unaffected —
  but it is named here because a client parsing that output strictly rejects the new key, and because
  leaving 47.2 unlisted would make a reader wonder what happened to it.

  The `unavailable` arm deliberately does **not** carry `triggered`: "the claim could not be joined to
  a stored result" and "the signal did not fire" are different states, and leaving the key off that
  arm makes conflating them a type error rather than a convention.

### Additive in the same span

- **Two tools join the catalog for the agent trade flow** (52.1.0, `add-mcp-agent-trade-flow`).
  `scan_agent_coins` evaluates every active coin against one of your agents in a single call and
  returns them server-ranked, on the same use case, buckets and ranking the app's own TRADE-tab scan
  serves. `propose_entry_decision` runs the conversational trade turn headlessly for one (agent,
  coin) and returns its terminal in the stream's own vocabulary: `type: recommendation` carrying the
  PROPOSED `TradingEntryDecisionDTO` row that `get_entry_decision` and `list_pending_approvals`
  already publish, `type: no_trade`, or `type: error` carrying the turn's `TradeConvError` verbatim.
  Nothing narrows and no existing schema hash moves; the 12-ticker `get_agent_coin_qualification`
  stays as the spot-check probe.

  **`idempotencyKey` is REQUIRED on `propose_entry_decision`** — it is the turn's own key, and a
  same-key retry replays the recorded terminal rather than paying for a second inference. That
  includes a post-billing `LLM_FAILURE`, which is returned as a value for precisely that reason.
  Pre-engine faults are typed errors instead: `RATE_LIMITED`, `NOT_FOUND`, `CONFLICT` for a same-key
  call still in flight, and `SERVICE_UNAVAILABLE` when the surface is switched off. Both tools
  refuse with `RATE_LIMITED` carrying `retryAfterSeconds` under the same per-(user, agent) limit the
  app itself enforces, so a scan is never served stale or partial.

- **`get_regime_snapshot` publishes the evidence behind the verdict** (47.1.0,
  `publish-regime-classification-evidence`). The snapshot gains `evidence`: the quantities the
  classifier read, the gates it tested them against, the signed margin to the gate deciding whether
  the current label survives, and the two decision facts only the classifier holds — `gateState`
  (`cleared` / `held` / `dropped`) and `directionSource` (`di` / `ema`). Nothing narrows; a client
  that ignores the field is unaffected.

  Read `gateState` before you trust a trend label: **`held` means the ADX hysteresis buffer is
  carrying the PREVIOUS bar's label rather than this bar re-confirming it** — a materially weaker
  claim wearing the same word, and one no client could previously detect. `directionSource: 'ema'`
  is the same shape of warning: the direction came from the fallback that fires precisely when the
  DI spread is indecisive. The margin is signed so **positive always means "the current label
  survives by this much"**, in every gate state, so it is safe to branch on its sign.

  `conviction` is a BRANCH DISCRIMINATOR, not a confidence: it encodes *which* rule in the priority
  ladder matched, not how comfortably it matched. The margins carry comfort. A client reading
  conviction as a strength score is reading it wrong, and always was — this release just makes the
  alternative available.

- **Thirteen metric keys join the catalog** (47.1.0) — the `regime` family gains `REGIME_STATE`,
  `REGIME_CONVICTION`, `REGIME_RUN_BARS`, `REGIME_TREND_GATE`, `REGIME_TREND_MARGIN`,
  `REGIME_TREND_SOURCE`, `REGIME_DI_SPREAD`, `REGIME_VOL_ATR_RATIO`, `REGIME_VOL_BBW_RATIO`,
  `REGIME_MOM_BULL_VOTES`, `REGIME_MOM_BEAR_VOTES`, `REGIME_CRASH_MARGIN` and `REGIME_CRASH_LATCH`,
  making the composite regime and its evidence addressable in a report column or condition for the
  first time. Only a client that switches exhaustively on `MetricKey` needs new branches.

  Not a contract change, but worth knowing if you author conditions: the report grammar's regime
  metrics now resolve from the **confirmed close** on every path. They previously resolved from the
  forming bar when a report was rendered and the confirmed close when the scan swept, so the same
  condition could read differently in preview than in production. Same wire shape; same bar
  everywhere now.

## Contract history — v12 → v36

> **The number in this heading is a CONTRACT version, not this package's version.** The npm badge at the top
> tracks the proxy's own code; this section tracks the server's wire contract. They move independently **by
> design**: a contract move needs no release here, because a connected proxy relays the contract out of the
> upstream handshake rather than declaring it. So a package on `31.x` listing contract history up to `36.x` is
> correct — not a version someone forgot to bump. Read the live pair from the startup stderr lines or
> `GET /mcp/version`; see [Rediscovery & versioning](#rediscovery--versioning) for why.

These are the server contract breaks between contract 12 and contract 36. Most of the span shipped while the package sat at `11.0.0`; contract 31 landed after this package reached `31.0.0`, and the two numbers matching is coincidence — since v31 the announced contract is relayed from the server, so a package version says nothing about a contract version. They are **contract** history, not package releases: from v31 the announced contract is relayed live and a contract move is no longer a release here. Grouped by what a client observes, with the contract version that introduced each.

**The proxy itself is unchanged.** It embeds no schemas, pins no contract version, and forwards `{ request }` verbatim. Every break below lands on whatever *authors* the payload or *reads* the result, never on the proxy.

### Changed meaning, unchanged shape — the one to read first

- **`compile_strategy_plan` is no longer read-only or idempotent** (33.0.0, `rehydrate-approved-plan-on-apply`). Its input, its output and its behaviour toward your strategy are unchanged — it still changes no strategy, agent or revision — but it now parks the plan it approved for its own apply to read, and **each call mints a distinct record and a distinct token**. `readOnlyHint` and `idempotentHint` are published as `false` accordingly. A client that retried a compile that had already succeeded, or fanned two out in parallel for one edit, was doing so on the strength of the old annotation: **do neither.** Compile once per reviewed payload. Nothing in the payload tells you this moved.

- **Position-size presets are now a RISK BUDGET** (30.0.0, `split-stop-geometry-from-risk`). `smallPct` / `mediumPct` / `largePct` stop denoting a share of the ORDER (`notional = pct / 100 × headroom × leverage`) and start denoting the share of headroom placed **at risk** (`notional = headroom × riskPct / stopDistancePct`, capped at the margin headroom can post). Same keys, same types, same accepted range: **nothing in the payload tells you the meaning moved.** A client still sending `22.0` for MEDIUM is asking to risk 22% of its budget on one trade rather than roughly 2%. Typical risk budgets are `0.5`–`3`; the platform defaults moved to `1 / 2 / 3`. Leverage stops multiplying order size and becomes a constraint only.

### Rejected input — something you author is no longer accepted

- **`upsert_deployment_policy` requires `enabled`** (35.0.0, `add-arena-deployment-pause`). The arena deployment gains an owner-owned pause, and the flag that carries it is **required, not optional** — a body accepted under contract 34 is refused under 35 without one byte of it changing. Required is the whole point: this call replaces the entire policy, so an omitted key and an explicit `true` would be the same request on the wire, and every client that rebuilt a policy without the flag would silently resume a deployment its owner had paused. Read the value from `get_deployment_policy` and send it back. **There is no separate pause verb** — pausing and resuming are this same call with the flag flipped.

  Do not reach for `enabled: false` to un-deploy. It keeps every slot and stops play, which is the opposite of withdrawing: `delete_deployment_policy` is the withdrawal verb, and it discards the rules permanently. `upsert_deployment_policy` refuses an empty slot set, and its rejection names both routes.

- **A signal rule flagged `required` at allocation Off is rejected** (34.0.0, `enforce-required-allocation-invariant`). `required` and `allocation` are two independently editable fields encoding one thing — whether and how a signal participates — so `{ required: true, allocation: 0 }` was representable and meant nothing: the scorecard's triggered set already excludes Off, so such a rule could neither satisfy `minRequiredCount` nor block a trade. It is now refused on **every** rule-writing surface — `compile_strategy_plan`, `apply_strategy_plan` and `update_strategy_signal_rule` alike.

  Three things make this one easy to trip over. It is an **input-acceptance narrowing**: a payload accepted under contract 33 is refused under 34 without one byte of it changing. It is **invisible in the published schema** — `zod-to-json-schema` drops effects by construction — so you cannot pre-validate it from `tools/list`, and the typed error IS the contract: read `details.inertRequiredSignalIds`, which carries the complete sorted list of offending signals, rather than the message, which names a bounded prefix. And **nothing is repaired for you**: the server will not raise the allocation (that would invent a scoring weight you never chose) nor clear the flag (that would discard your intent silently). Pick one and resend; either satisfies the boundary.

- **`apply_strategy_plan` no longer accepts the plan** (33.0.0, `rehydrate-approved-plan-on-apply`). Its input narrows to `{ planToken, confirm }`. A `plan` member is **rejected as an unknown key** — not accepted, not ignored, and with no transitional dual shape — so every client that built the payload breaks on the next connection, which before this is what every published surface told it to do. The server keeps the plan its own compile approved and reads it back, so **you copy nothing out of the compile response**: forward `planToken` byte-for-byte and confirm.

  Three consequences worth knowing. The 256,000-byte cap on the apply payload is **gone with the payload**, so a large authored surface no longer becomes impossible to apply through a conversational client; the compiled plan is still capped and compile still enforces it. `PLAN_APPROVAL_NOT_FOUND` joins the error vocabulary for a token no approved plan answers to — already applied, lapsed, or never issued, all one code, because the recovery is the same in each case: compile again. And a validation refusal (a quota, a name collision, a bound agent that changed, a moved catalog) now **leaves the approved plan applicable** — clear the cause and confirm again with the same token while it lives, rather than recompiling.

- **The agent brain is no longer a preset union** (32.0.0, `remove-agent-presets`). `create_intelligence_agent` stops taking the `brain` discriminated union: `modelId` and `behavior` become required top-level fields and the `{ kind: 'PRESET' | 'CUSTOM' }` wrapper is gone, so the old shape fails on the unknown key **and** on two now-missing required fields. `update_intelligence_agent` drops `brainPreset`; `modelId` and `behavior` stay independently optional and are now **always honoured**, closing an accept-and-ignore where a named preset silently discarded a model sent beside it.

- **`apply_strategy_plan` now publishes the same bounds as `compile_strategy_plan`** (31.0.0, resolving #4495). Eleven position-management dials were declared three times server-side and two copies had drifted, so apply advertised `trailingGivebackPct` as a bare number where compile advertised 25–55, and dropped `trailingTriggerR`'s `multipleOf 0.01` — the constraint pinning storage precision so a sub-precision value is rejected rather than rounded onto the trail-from-entry sentinel `0`. Nothing bad could ever commit (the digest would not match), but the refusal you got was a binding mismatch, which arrived as a bare `INTERNAL_ERROR`. Apply's published bounds only **narrow** to compile's; a client that copies values from `approvedPlan.postState`, as it should, is unaffected. Separately the trio `minStopLossAtrMultiple` / `maxStopLossAtrMultiple` / `minRiskRewardRatio` is published as a bare declaration by both tools, its real bounds being runtime-tunable and inexpressible in JSON Schema.

- **The stop-loss ceiling changed unit** (30.0.0). `maxStopLossPct` (a percent of entry) becomes `maxStopLossAtrMultiple` (a multiple of ATR), and the accepted range narrows from `(0, 100]` to `(0, 3]`. It is a rename **and** a re-denomination — mapping the old value onto the new key sends a number one to two orders of magnitude too large. The objects are `.strict()`, so a 29.x client sending `maxStopLossPct` is rejected with an unknown-key error. A new cross-field rule comes with it: `minStopLossAtrMultiple < maxStopLossAtrMultiple` is now a real comparison and is enforced.
- **The grid-confidence and trade-conviction bars left the agent** (28.0.0, `remove-agent-rule-defaults`). `tradingConfig.gridMinConfidence` and `minTradeConviction` are removed from the shared `.strict()` config; a bar is declared on the arena slot or radar slot that fires.
- **The agent no longer carries either entry guard** (26.0.0). `create_agent` and `update_agent` stop
  accepting `tradingConfig.signalTimeoutMinutes` and `tradingConfig.maxEntryDeviationAtrMultiple`.
  The schema is `.strict()`, so a client still sending either is **rejected**, not silently ignored.
  Neither has a replacement key on any surface: unlike the strategy-owned fields below, these have no
  owning surface at all beneath the platform. One `platform_config` value governs the entry-price
  drift budget for every decision, read at evaluation time; one governs how long an entry may stay
  unfilled, snapshotted onto the position at creation. **Remove both keys and send nothing in their
  place.**
- **The arena stopped granting trade authority** (25.0.0, `remove-arena-trade-permissions`). `upsert_deployment_policy` and `preview_deployment_resolution` stop accepting `tradingEnabled`, `minConviction` and `coinRules[]` on a slot.
- **The agent no longer carries an exit policy** (24.0.0). `create_agent` and `update_agent` stop
  accepting `tradingConfig.positionManagement`. The schema is `.strict()`, so a client still sending
  it is **rejected**, not silently ignored. The twelve dials that decide how a stop MOVES after entry
  — break-even arming, trailing engagement and giveback, time-decay tightening — are denominated in
  the setup's own payoff shape (multiples of the trade's initial risk, fractions of take-profit
  distance, minutes since entry) and read no balance, leverage or exposure, so they belong to the
  thesis rather than to the account running it. **Author them on the strategy instead**, through
  `compile_strategy_plan` / `apply_strategy_plan`: the post-state gains the same twelve keys beside
  the trade-level trio, and the plan diff gains a `positionManagement` axis. An agent inherits the
  policy from the strategy it binds. **The umbrella `enabled` flag is deleted rather than moved** —
  each mechanism's toggle is now the whole truth for that mechanism, so "trailing on, management
  off" is no longer expressible, a state the server's own monitor and boot recovery already
  disagreed about.
- **The agent-level trading mode is gone** (23.0.0). `create_agent` and `update_agent` stop
  accepting `tradingConfig.tradingMode`. The schema is `.strict()`, so a client still sending it is
  **rejected**, not silently ignored. Trading on/off is now scoped per deployment — a radar policy's
  `enabled`, an arena slot's `tradingEnabled`, a per-coin `tradeEnabled` — and approval-before-
  execution is the conversational surface's own contract, so the account-level switch that sat above
  both is removed rather than renamed. To stop an agent trading, turn its deployment off (or halt
  the agent); to make one trade autonomously, arm a radar coin or switch trading on for an arena
  slot. **A newly authored arena slot now starts with trading off.**
- **Trailing gained a required threshold** (22.0.0, `add-trailing-trigger-r`). `positionManagement` gains `trailingTriggerR` as REQUIRED (`0`–`2.0`, `0.01` precision, `0` = trail from entry); the object is `.strict()` all-required, so sending `positionManagement` without it is rejected.
- **The strategy regime timeframe became derived** (19.0.0, `remove-strategy-regime-override`). It stops being an authored axis anywhere on the contract and is served read-only.
- **Conditions gained a required `required`** (16.0.0, `add-condition-enforcement-gate`). A condition entry omitting the boolean is REJECTED rather than defaulted.
- **The trade-level policy moved to the strategy** (15.0.0, `move-trade-level-policy-to-strategy`). It leaves the agent authoring surface and joins the setup gates on the strategy.
- **The agent's ATR timeframe axis is gone** (14.0.0, `remove-agent-atr-timeframe-axis`). ATR is sampled on the strategy timeframe, always.
- **Radar's wall-clock condition changed shape** (12.0.0, `unify-deployment-hours-as-sets`).

### Moved or reshaped output — a field you read is somewhere else

- **The signal scorecard stops serializing its entries three times over** (36.0.0, `mcp-signal-log-contents`). `get_signal_log` and `get_public_agent_signal_log_detail` drop `scorecard.triggeredSignals`, `scorecard.primarySignals` and `scorecard.supportingSignals`. Every one held the **same entry objects** `allEvaluatedSignals` already carried, so each is one filter over flags every entry still publishes:

  | Removed | Read instead |
  |---|---|
  | `triggeredSignals` | `allEvaluatedSignals.filter(s => s.triggered)` |
  | `primarySignals` | `allEvaluatedSignals.filter(s => s.triggered && s.isPrimary)` |
  | `supportingSignals` | `allEvaluatedSignals.filter(s => s.triggered && !s.isPrimary)` |

  **Keep the `triggered` half of those last two predicates.** Both collections were triggered-only by construction, so filtering on `isPrimary` alone surfaces signals that never fired — a silent widening, not an error. No field is removed from an entry: the key set on an `allEvaluatedSignals` member is unchanged, every evaluated signal is still returned whether or not it triggered, and `details` prose and `indicatorValues` are intact. There is no opt-in to get the three back and no default filter. Breaking only if you read one of the three names; on an 84-signal / 16-triggered log the duplication was 10,682 bytes, 28% of the scorecard, carrying no information.

- **The fleet roll-up on `list_deployment_policies` drops `unconfigured` and gains `paused`** (35.0.0, `add-arena-deployment-pause`, `fix-arena-deployment-undeploy`). `unconfigured` counted a deployment holding zero slots — a state that can no longer exist, because a stored policy now carries at least one slot and the withdrawn state is the **absence** of a policy rather than an empty one. The bucket was constant `0` at the moment of removal, so no number you read was wrong; a client reading the key still breaks on it, which is why this is a break and not a cleanup. `paused` is the owner's own switch, counted separately from `retired` — an administrator disabling the arena — because conflating them tells an owner to wait for something that will not happen. Every policy lands in exactly one bucket, so the buckets sum to `arenas`: worth asserting if you reconcile these counts.

- **`AdminApprovedModelDTO.isActive` became `lifecycle`** (32.0.0, `remove-agent-presets`) — `AVAILABLE` / `DEPRECATED` / `RETIRED`. The boolean conflated "offered in the picker" with "bound agents may run", so there was no way to stop offering a model without hard-blocking every agent already on it. A client switching on `isActive` must switch on `lifecycle`, and **must not treat `DEPRECATED` as blocked**: that is the state which keeps bound agents running. The agent read DTO drops `brainPreset` in the same move — the marker recorded which named bundle an owner clicked, never a value the runtime read, and the model and soul it stamped are unchanged on every agent.

- **`approvedPlan` is one object, not an operation union** (31.0.0, resolving #4495). It was published as a discriminated union whose discriminator does not survive JSON-Schema conversion, so what actually shipped was a bare `anyOf`: validating a failing compile response gave you every arm's errors with empty instance paths, and the top one typically complained that an UPDATE was missing `creationSeed` — a CREATE-only key — while the field that really failed went unnamed. It is now one object with a literal `operation` discriminator, and `creationSeed`, `expectedRevision` and `bindingImpact` are **required and nullable on every operation**: a CREATE plan carries a seed and `expectedRevision: null`, an UPDATE/RESTORE plan the reverse. **If you narrowed on the union arms, read `operation` instead and expect explicit `null`s rather than absent keys.** If you read those fields without narrowing, nothing changes except that they may now be null.

- **`get_radar_activity` gained an `EDGE_REARM` variant** (29.0.0, `add-radar-anchor-rearm`), and every member gained five `rearm*` margin keys plus a `rearmReasons` discriminator. Breaking on both counts if you parse the union strictly.
- **`get_trading_config_catalog` drops four trade-default seeds** (27.0.0) — `defaultMinAtrPct`,
  `defaultMinStopLossPct`, `defaultMaxStopLossPct` and `defaultMinRiskRewardRatio` leave `defaults`.
  Each seeded a per-agent field that is now **strategy-owned**: the shared `.strict()`
  `TradingConfigSchema` already rejected all four as unknown keys, so the catalog was advertising
  defaults no request could apply. A client that wants the stop-loss band, the ATR floor or the
  risk-reward minimum reads them from the bound **strategy**, which owns and materializes them.
  Nothing is added in their place. `defaultMaxEntryDeviationAtrMultiple` and `defaultTtlMinutes` are
  untouched — those are not seeds but the platform values that govern.
- **`get_radar_activity` gained `blockReasonCode` on every member** (21.0.0, `fix-block-reason-attribution`) — non-null only on `BLOCKED_BEFORE_EVALUATION` rows written after 2026-08-17.
- **`signal_pipeline`'s decision became a discriminated union** (20.0.0, `add-decision-skip-attribution`). `ENTER`/`GATED` carry the seven level fields as REQUIRED; `SKIP` omits them entirely rather than sending nulls, so reading `entryPrice` without narrowing the verdict finds the key absent.
- **`get_radar_activity` gained an `EVALUATION_OUTCOME` member** (18.0.0, `add-radar-fire-outcome-journal`), and every existing member gained `evaluationOutcome` + `screenReason`.
- **Scalar families became placeable modules** (13.0.0, `add-scalar-family-modules`); six opt-in scalar headers moved off the shared `session-field` section key.
- **Both entry guards leave every agent-returning shape** (26.0.0) — the read side of the input
  removal above. `AgentTradingConfigDTO` drops `signalTimeoutMinutes` and
  `maxEntryDeviationAtrMultiple` on every tool that serves an agent, and the explorer trading spec
  and the agent-review payload drop them too. `get_trading_config_catalog` drops
  `defaultSignalTimeoutMinutes` from its defaults and the
  `minimumMaxEntryDeviationAtrMultiple` / `maximumMaxEntryDeviationAtrMultiple` pair from its bounds —
  a bound pair that constrained a per-agent field which no longer exists.
  `defaultMaxEntryDeviationAtrMultiple` and `defaultTtlMinutes` **stay**, and are now the values that
  actually govern rather than seeds a new agent copies. A client reading these objects strictly must
  drop the removed keys.
- **`positionManagement` leaves every agent-returning shape** (24.0.0) — the read side of the input
  removal above. `AgentTradingConfigDTO` drops the nested block on every tool that serves an agent,
  and the explorer trading spec drops it too. A client reading these objects strictly must drop the
  key; one that wants the policy reads it from the bound strategy.
- **`get_trading_config_catalog` drops `positionManagementPresets`** (24.0.0) — the pistol ladder
  (COLT / WEBLEY / BERETTA / LUGER / WALTHER) is **retired, not renamed**. Once the values live on
  the strategy, the strategy IS the named bundle, with its own name, description and revision
  history; a parallel vocabulary of anonymous bundles beside it would be a second name for the same
  thing. There is no replacement enum to migrate to — list strategies instead. The catalog's
  `defaultPositionMgmt*` trading defaults go with it, for the same reason: nothing seeds an agent's
  exit policy any more.
- **`tradingMode` leaves every agent-returning shape** (23.0.0) — the read side of the input
  removal above. `AgentTradingConfigDTO` drops it on every tool that serves an agent, and so do the
  agents-hub permission envelope, the explorer entry, and both public-profile shapes. A client
  reading these objects strictly must drop the key.
- **`DeploymentResolvedResolutionDTO` drops `agentTradingMode`** (23.0.0) — the field 10.0.0 added,
  now unnecessary: with no account layer to overlay, the resolved `tradingEnabled` is the whole
  answer about whether the previewed deployment trades.

### Widened enum — new members your own copy rejects

- **`DeploymentResolutionStatus` gains `PAUSED`** (35.0.0, `add-arena-deployment-pause`), returned by `get_deployment_policy`, `list_deployment_policies` and `preview_deployment_resolution`. A client switching exhaustively on the status must add the branch. Two properties are not obvious from the name: it is answered **before any slot is resolved**, so a paused deployment discloses no agent identity and carries `regimeUsed: null`; and its `targetSession` is nullable, because a deployment can be paused on an arena with no upcoming session and the pause is still the true answer. Do not re-derive the pause from the `enabled` flag beside it — the served status is the answer on every path, and those two disagreeing is the defect this closed.

- **Seven plan-token failures became their own error codes** (31.0.0, resolving #4495). `TOKEN_EXPIRED`, `TOKEN_BINDING_MISMATCH`, `INVALID_TOKEN_SIGNATURE`, `INVALID_TOKEN_FORMAT`, `INVALID_TOKEN_CLAIMS`, `INVALID_DIGEST_MATERIAL` and `INVALID_MATERIALIZATION_FENCE` all used to arrive as a bare `INTERNAL_ERROR` — the server wrote the true reason to its own audit log and discarded it at the boundary, so a refused apply told you nothing. They now arrive as themselves, over MCP and HTTP alike, with 409-class status for the two state-conflict codes and 400-class for the five malformed-material codes. A client switching exhaustively on error codes must add the branches; one rendering unknown codes generically is unaffected. Two are worth handling by name: `TOKEN_EXPIRED` means recompile (the token lives five minutes), and `INVALID_TOKEN_SIGNATURE` usually means the token was not forwarded verbatim — it is opaque, so copy it byte-for-byte and never retype or reconstruct it.

- `TradeEvaluationAttemptReasonCode` gains `OPEN_POSITION_CHECK_UNAVAILABLE` (19.4.0), splitting a code that previously reported a platform fault as a fact about your account.
- `QualificationGateCode` gains `REQUIRED_CONDITION_FALSE` (19.3.0), from the SCAN-stage gate that now evaluates required conditions before a fire edge is spent.
- `TradingPipelineGateStage` gains `EVALUATION` and `TradeEvaluationAttemptReasonCode` gains `EVALUATION_FAULTED` (18.2.0).

### What you do NOT need to do

Nothing in the proxy changes. No configuration, no environment variable, no call-shape change on this package's own surface. If your client discovers tools live and reads results generically, `npm i @battlegrid/mcp-server@30` is the whole upgrade.

### Additive in the same span

`27.1.0` exit-policy authoring input on `compile_strategy_plan` · `19.2.0` `get_account_state` account identity · `19.1.0` Standing Orders marker authoring · `18.4.0` `list_gate_blocks` summary groups · `18.3.0` radar maintenance pause · `18.1.0` protection geometry · `17.2.0` break-even/trailing status · `17.1.0` `get_signal_log` condition evaluation · `13.1.0` four owner-scoped read tools · `12.1.0` cross-venue spot price metrics · `11.1.0` discoverable rate limit.

## v11 and earlier — contract history (v6 → v11)

> **Historical.** These notes are keyed to the package versions that once paired with contract versions. That pairing ended at v31 (above); the breaks themselves are still real, and still describe the server's contract as it moved from 6.0.0 to 11.0.0.

**v11 paired with the BattleGrid server's MCP contract v11.x.** Under the pairing rule then in force, the package version tracked the server's wire contract because the proxy announced `battlegrid@<package version>` in its own stdio handshake.

This release absorbs **six** breaking contract majors at once. The published package went from `5.0.0` straight to `11.0.0`, so no `6.x` through `10.x` client exists to upgrade from — the breaks are therefore grouped by **what you will observe**, with the contract version that introduced each, so a client hitting a specific rejection can find it here.

**The proxy itself is unchanged.** It embeds no schemas, pins no contract version, and forwards `{ request }` verbatim. Every break below lands on whatever *authors* the payload or *reads* the result, never on the proxy.

### Rejected input — something you author is no longer accepted

- **Challenge participation is no longer a field you set** (10.0.0). `create_agent` and `update_agent` stop accepting `arenaChallengeEnabled`, and a deployment policy's slot rules and per-coin rules stop accepting `challengeEnabled`. All four schemas are `.strict()`, so a client still sending any of them is **rejected**, not silently ignored. Challenge participation is now *identical* to effective trade permission, resolved per coin: to stop an agent taking challenges at a venue, turn that venue's trading off — at the slot, or per coin for finer grain — using fields you already have.
- **`VOLUME_RATIO` is retired and replaced by `RVOL`** (6.0.0). `MetricKeySchema` auto-derives from the server's metric catalog, so the published enum simply stops accepting the old key: a column authored with `metric: 'VOLUME_RATIO'` is rejected against the enum. **No alias exists** — deliberately. The catalog audit adjudicated the old name as a name-level lie (the value is current volume ÷ its 20-period average, a *multiple*), and the correction was made at the root rather than grandfathered. If you hit an enum rejection naming `VOLUME_RATIO`, this note is the match.
- **`BB_WIDTH` can no longer be ranked** (8.0.0). `{ metric: 'BB_WIDTH', transformId: 'rank' }` is now rejected. `BBwidth` is a price-unit spread (`upper − lower`) that had falsely declared `percent`, and that declaration was the only thing admitting it to exchange-wide ranking — the ordinal it produced sorted by token denomination rather than by compression. It re-declares `signedPrice` and leaves the ranked contract. **Rank `BB_WIDTH_PCT` (`bbWidthPct`) instead**, which ships in the same release: the capability moved, it was not removed.

### Silently non-matching — a generated header you match on moved

This is the failure mode with no error attached to it. Nothing is rejected; your matcher simply stops finding the column.

- **`vol` → `RVOL`** (6.0.0), and with it **`vol_trend` → `RVOL_trend`** and **`vol_rank_hi` → `RVOL_rank_hi`**. The header code moves with the metric key, because every generated header over that metric derives from the code alone.
- **`BBwidth_rank_lo` no longer exists** (8.0.0) — it was the header of the ranked pairing retired above.

### Moved or reshaped output — a field you read is somewhere else

- **`estimatedTokenCount` is gone** (7.0.0). `preview_strategy_report` and `compile_strategy_plan` no longer carry it; the same number now sits one level deeper as **`budgetUsage.estimatedTokens.used`**, paired with the `cap` that governs it — so a client reading the count changes one path and gains the ceiling it was never told. `tokenCountModel` is unchanged. Discovery grows to match: `list_strategy_vocabulary` and the report catalog add `budgets.estimatedTokens` and a `previewExecutionLimits` object carrying the serialized-result byte cap and the preview deadline. Those two are published **cap-only** and deliberately have no `used` companion.
- **`approvedPlan.mismatches` changed on both axes** (9.0.0). Both report-coverage codes are renamed off "module", because the module is no longer the unit of coverage:

  | Was | Is now |
  |---|---|
  | `ACTIVE_SIGNAL_MODULE_NOT_IN_REPORT` | `ACTIVE_SIGNAL_DATA_NOT_IN_REPORT` |
  | `REPORT_MODULE_SIGNAL_OFF` | `REPORT_DATA_SIGNAL_OFF` |

  Each mismatch also carries a **required** `data: CoverageDatum[]` — the `(metric, rung)` pairs the mismatch is about: every MISSING datum for the not-in-report code, the PRESENT data for the signal-off code, empty for `REQUIRED_SIGNAL_UNAVAILABLE`. A client switching exhaustively on the old code strings stops matching. Behaviourally, coverage is now decided by whether the report renders a signal's declared metrics at the **rung** that signal reads, not by whether its module appears anywhere — so expect warnings you never saw before, and the disappearance of warnings no composition could clear. Mismatches remain advisory and non-blocking; nothing about apply gates on them.
- **`IntelligenceAgentDTO` drops `arenaChallengeEnabled`** (10.0.0) — the read side of the input removal above. `ResolvedSlotRulesDTO.challengeEnabled` **stays and keeps its shape**; it is now derived server-side, carrying the same value and provenance as `tradingEnabled`, so a client reading the resolved bundle needs no change.
- **`range` is no longer a tuple** (11.0.0). The closed positional pair `[min, max]` becomes the half-open object **`{ min: number; max?: number }`**. It travels through `ScalarSchema`, so this lands on `list_strategy_vocabulary`, `query_report_catalog`, and `get_metric_construction_hints` alike.

  **This one fails silently.** A client reading `range[0]` / `range[1]` gets `undefined` with no error raised — read `range.min` / `range.max` instead, and treat a missing `max` as unbounded above. The tuple could not state the truth about the volume/trade-count family, which is non-negative and unbounded above, and `Infinity` serializes to `null` on the wire. Those six metrics now declare `{ min: 0 }`, and as a consequence their **`far`/`near` rank orderings are no longer offered** — on a non-negative value that pair is a synonym pair under the magnitude gate's documented semantics.

### Widened enum — new members your own copy rejects

- **The published `unit` enum gains `ratio` and `fraction`** (8.0.0), and `RVOL`, `BUY_PRESSURE`, `BB_PCT_B` now emit them instead of `percent` — none of the three is a percentage, and `percent` appended a false `%`. This is a break in the opposite direction from the others: nothing you send is rejected, but a client holding **its own closed copy** of the unit enum rejects the new members. Easy to mistake for a purely additive change. Values are **not** rescaled — `buyPres` and `pctB` stay 0–1, so persisted thresholds comparing against `0.5` keep their meaning.

### What you do NOT need to do

- **No stored strategy needs client action.** Every persisted reference of both kinds — metric keys in section-column rows and revision snapshots, and everything addressing a column by its generated header — was migrated server-side by `20260805120000_rename_volume_ratio_to_rvol.sql`. This is a **client-literal problem only**; there is no stored record to repair.

### Additive in the same span

Nothing here is a break, but a client that enumerates these vocabularies will want them:

- **Four metric keys** join the catalog — `SPOT_CVD`, `PERP_SPOT_FLOW`, `PERP_SPOT_STRENGTH`, `PERP_SPOT_CONFIRMS` — and `includePerpSpotFlow` joins the context-source key set as its 23rd member, opt-in (5.2.0).
- **Two prompt-section `kind`s** join the union: `perp-spot-flow` (5.2.0) and `session-field` (5.1.0). Only a client that switches exhaustively on `kind` needs a default branch; one that renders `content` generically needs nothing.
- **The transform vocabulary grows 15 → 17** (11.0.0) — `efficiency` and `maxShare` join, and both join the chain-outer enum that `chainSuccessors` is served as. Only a client that switches exhaustively on `transformId` needs new branches; one that renders the served labels generically needs nothing.
- **The deployment-policy resolution DTO gains `agentTradingMode`** (10.0.0) — the preview previously reported trade rules for an agent whose *account-level* trading was off, and the account gate is not part of rule resolution. Read it to tell "this rule permits the trade" from "this account can trade at all".
- **`PlatformSectionDTO` gains `columns`** (6.1.0) — a platform section's composition in the same wire shape a custom section's columns already travel in, empty for a registry-declared special. **Not a copy source**, which is where it differs from the identically-shaped `CustomSectionTemplateDTO.columns`: six platform columns pair a metric with `classifyState`, a deliberate composability exclusion that authoring rejects at construction. A client that round-trips these into a custom section will be refused, and that refusal is correct.

## v5 — breaking major (conditions/verdicts fusion)

Retained for authors upgrading from 4.x. Everything below still describes the current contract.

- **`conditionVerdicts` no longer exists.** The verdict now rides the condition that decides it, and precedence is the conditions' own declaration order rather than a separate ordered map:

  ```jsonc
  // v4 — two parallel arrays joined by string key
  {
    "conditions":        [ { "conditionKey": "UP_FADE", "name": "…", "definition": { /* … */ } } ],
    "conditionVerdicts": [ { "when": "UP_FADE", "then": "UP" } ]
  }

  // v5 — one array; the verdict rides its condition
  {
    "conditions": [ { "conditionKey": "UP_FADE", "name": "…", "definition": { /* … */ }, "verdict": "UP" } ]
  }
  ```

  `verdict` is **required and nullable**, never optional: a building block that decides nothing spells its absence as an explicit `null`, never by omitting the key. An omitted `verdict` is a rejected payload, not a defaulted one.

- **A submitted `conditionVerdicts` is REJECTED, not ignored.** Deliberately — a v4 client that forwards the retired field is told what replaced it instead of getting an anonymous unrecognized-key rejection:

  > `conditionVerdicts` was retired in contract 5.0.0 — a condition now carries its own `verdict` (`UP` | `DOWN` | `NEITHER`, or `null` for a building block). Move each mapping onto the condition it named and resubmit.

- **The authorable verdict domain narrowed to three.** `conditionGrammar.verdicts` advertised `['UP','DOWN','NEITHER','UNRESOLVED']` and now advertises `['UP','DOWN','NEITHER']`. `UNRESOLVED` is an *evaluation outcome* — "a deciding condition could not be evaluated" — never an authored intent, so advertising it offered a value the schema then rejected. Any client mirroring the advertised enum into its own validation must narrow with it.

- **The evaluated per-coin verdict is nullable.** Resolution is first-TRUE-decides over the verdict-carrying conditions in declaration order, and its four outcomes are all distinct claims — collapsing any pair loses information a reader needs:

  | Evaluated verdict | Means |
  |---|---|
  | `UP` / `DOWN` / `NEITHER` as a **decision** | The first verdict-carrying condition that resolved TRUE declared it |
  | `NEITHER` as a **fallthrough** | Every verdict-carrying condition resolved FALSE |
  | `UNRESOLVED` | No carrier fired and at least one could not be evaluated — "could not be read", not "read as no setup" |
  | `null` | The strategy declares no verdict-carrying condition at all — it expresses no direction |

  `null` is new in v5; it previously surfaced as `NEITHER`. A client that renders the verdict must handle it without collapsing it into `NEITHER`.

- **The proxy itself is unchanged.** It embeds no schemas, pins no contract version, and forwards `{ request }` verbatim, so the whole break lands on whatever builds the apply projection. Apply takes an allowlisted projection of `approvedPlan`, never the object itself (`diff`, `viability`, `mismatches`, `signalRules`, `creationSeed`, `proposedRevision`, `bindingImpact`, `authoringCatalogDigest` are rejected as unknown keys, and so are the `postState` fields apply does not accept: `id`, `scope`, `systemKey`, `visibility`, `cadence`, `isActive`, `forkedFromStrategyId`). The exact field list is step 5 of **Strategy authoring (compile → review → apply)** below. A client that forwards post-state fields generically — stripping the derived keys rather than enumerating the kept ones — picks the fusion up without an edit; one that enumerates the fields it copies must drop `conditionVerdicts` from that list.

The v3 authoring contract below is unchanged and still current:

- **Strict authoring envelopes.** `get_strategy_section_template`, `update_strategy_signal_rule`, `compile_strategy_plan`, and `apply_strategy_plan` publish one strict server-owned object, `{ request: canonicalPayload }`. In multi-account mode the proxy adds `account` **only** as a sibling of `request`, producing exactly `{ account, request }`; on a call it strips only `account` and forwards the unchanged `{ request }`. It never descends into, flattens, or reconstructs the nested request.
- **`create_strategy` is retired.** Direct strategy creation no longer exists. Author strategies with the compile → review → apply workflow below, and bind them to agents at agent-creation time (`create_intelligence_agent({ …, strategyId })`). There is no alias, shim, or flat-payload fallback.
- **Rediscover after deployment.** Publishing the package does **not** refresh a running proxy's cached capability snapshot. After the server cutover, restart/reconnect the proxy process and re-run `tools/list`, `prompts/list`, and `resources/list`.

> Earlier majors: **v1.x** single/multi-account stdio proxy; **v2.0.0** moved the default `BATTLEGRID_API_URL` to the `/mcp` suffix; **v3.0.0** the strategy-authoring major; **v4.0.0** made `conditions` and `conditionVerdicts` required on the apply post-state; **v5.0.0** fused the conditions/verdicts split (section above). **v6.0.0** through **v11.0.0** were never published as separate package versions — they are absorbed by the v11 cutover at the top. See [Rediscovery & versioning](#rediscovery--versioning).

## Quick Start

### Remote server, OAuth — start here

```
https://mcp.battlegrid.trade/mcp
```

Give that URL to your MCP client over its streamable-http (remote) transport and authorize: the
client registers itself by Dynamic Client Registration, BattleGrid's consent page opens in your
browser, and you sign in and click **Authorize**. No npm install, no API key. The grant is listed —
and revocable — under **Profile → MCP → OAuth Sessions**.

### API key and the stdio proxy — the fallback

Reach for a key when your client has no remote transport at all, when your agent runs headless or in
CI and cannot open a browser to consent, or when one process drives several BattleGrid accounts. It
is fully supported for each of those, and nothing about it is deprecated.

**Single account (stdio transport):**

```bash
BATTLEGRID_API_KEY=bg_live_xxx npx @battlegrid/mcp-server
```

**Multiple accounts (stdio transport):**

```bash
BATTLEGRID_API_KEYS=bg_live_alice_key,bg_live_bob_key npx @battlegrid/mcp-server
```

When multiple keys are provided, the server discovers each account's identity and injects a required `account` parameter into every tool so the AI agent can choose which account to act as. OAuth has no equivalent — one grant authorizes one account.

## Configuration

### Claude Desktop

**OAuth (no key):** Settings → **Connectors** → **Add custom connector**. Paste
`https://mcp.battlegrid.trade/mcp`, save, and authorize on the consent page Claude opens.

**API key (fallback) — single account:**

```json
{
  "mcpServers": {
    "battlegrid": {
      "command": "npx",
      "args": ["@battlegrid/mcp-server"],
      "env": {
        "BATTLEGRID_API_KEY": "bg_live_xxx"
      }
    }
  }
}
```

**API key (fallback) — multiple accounts:**

```json
{
  "mcpServers": {
    "battlegrid": {
      "command": "npx",
      "args": ["@battlegrid/mcp-server"],
      "env": {
        "BATTLEGRID_API_KEYS": "bg_live_alice_key,bg_live_bob_key"
      }
    }
  }
}
```

### Claude Code

**OAuth (no key):**

```bash
claude mcp add --transport http battlegrid https://mcp.battlegrid.trade/mcp
```

Then start `claude`, run `/mcp`, select **battlegrid** and choose **Authenticate** — the consent page
opens in your browser and the entry reads connected once you authorize.

**API key (fallback):**

```bash
# Single account
claude mcp add battlegrid -e BATTLEGRID_API_KEY=bg_live_xxx -- npx @battlegrid/mcp-server

# Multiple accounts
claude mcp add battlegrid -e BATTLEGRID_API_KEYS=bg_live_alice_key,bg_live_bob_key -- npx @battlegrid/mcp-server
```

### Cursor

**OAuth (no key):** Settings → **MCP** → **Add new global MCP server** opens `~/.cursor/mcp.json`.

```json
{
  "mcpServers": {
    "battlegrid": {
      "url": "https://mcp.battlegrid.trade/mcp"
    }
  }
}
```

Back in Settings → MCP, click **Needs login** on `battlegrid` and authorize on BattleGrid's consent
page; the entry turns green once its tools load.

**API key (fallback):** the same file, with the stdio proxy in place of the remote entry.

```json
{
  "mcpServers": {
    "battlegrid": {
      "command": "npx",
      "args": ["@battlegrid/mcp-server"],
      "env": {
        "BATTLEGRID_API_KEY": "bg_live_xxx"
      }
    }
  }
}
```

Use `BATTLEGRID_API_KEYS` (comma-separated) for multiple accounts.

### ChatGPT Desktop

ChatGPT Desktop connects via **OAuth 2.1** — no npm package or API key needed. ChatGPT handles the OAuth flow automatically.

1. Open ChatGPT Desktop → **Settings** → **MCP Servers** → **Add Server**
2. Enter the MCP endpoint URL: `https://mcp.battlegrid.trade/mcp`
3. Select **OAuth** as the authentication method
4. ChatGPT discovers OAuth endpoints, registers as a client (Dynamic Client Registration), and opens BattleGrid's consent page
5. Log in to BattleGrid and click **Authorize**

Authentication is a property of the **path**, not of the client — every client above reaches
BattleGrid either way, so pick the row that matches your runtime rather than your client:

| | Remote + OAuth | API key |
|---|---|---|
| **Transport** | streamable-http, direct to `mcp.battlegrid.trade` | stdio proxy (`@battlegrid/mcp-server`), or the same URL with a Bearer header |
| **Auth** | OAuth 2.1 with Dynamic Client Registration | API key (`bg_live_*`) as a Bearer token |
| **Setup** | paste the URL, authorize in the browser | npm package + env vars |
| **Needs a browser** | yes, once, to consent | no — works headless and in CI |
| **Multi-account** | one grant per account | `BATTLEGRID_API_KEYS`, several accounts through one proxy |

## Account management

### Single account

Set `BATTLEGRID_API_KEY` with one API key. All tool calls use that account, and the tools are exactly the server-native shapes — the authoring tools take the strict `{ request }` envelope with no `account` field.

### Multiple accounts

Set `BATTLEGRID_API_KEYS` with a comma-separated list of API keys (one per BattleGrid account). On startup the proxy:

1. Calls `GET /mcp/identity` for each key to discover the account username
2. Injects a required `account` enum parameter into every tool — as a **sibling** of the existing input, never nested inside it
3. Routes each tool call to the correct account using the matching Bearer token, stripping only `account` before forwarding

For the strict authoring tools, the multi-account input is exactly `{ account, request }`:

```json
{
  "name": "compile_strategy_plan",
  "inputSchema": {
    "type": "object",
    "properties": {
      "account": {
        "type": "string",
        "enum": ["alice", "bob"],
        "description": "Which BattleGrid account to use for this action"
      },
      "request": {
        "oneOf": [
          { "properties": { "operation": { "const": "CREATE" } } },
          { "properties": { "operation": { "const": "UPDATE" } } },
          { "properties": { "operation": { "const": "RESTORE" } } }
        ]
      }
    },
    "required": ["account", "request"],
    "additionalProperties": false
  }
}
```

The proxy consumes only the outer `account` and forwards the unchanged `{ request }` upstream. Never put `account` inside `request`, and never flatten request fields beside it.

If a key fails identity discovery (revoked, invalid), it is skipped with a warning. If all keys fail, the process exits. `BATTLEGRID_API_KEYS` takes precedence over `BATTLEGRID_API_KEY` when both are set.

### Getting an API key

1. Go to [battlegrid.trade](https://battlegrid.trade) → **Profile** → **MCP** tab
2. Generate an API key (format: `bg_live_*`)
3. Copy the key immediately — it is shown only once

Each account supports one active key at a time. Generating a new key automatically revokes the previous one — restart any running proxy process afterward, since keys are read once at startup.

For paid games and autonomous wagering, enable **Server-Signed Wagers** in the MCP tab (`mcp:wager` scope). Strategy discovery and non-financial configuration writes only need `mcp:read`.

## Strategy authoring (compile → review → apply)

Strategies are authored through one strict, whole-plan workflow. **Compilation changes no strategy, agent or revision; `apply_strategy_plan` is the only write to the strategy itself** — but compile is not read-only either: it parks the plan its own apply reads, and each call mints a distinct record and token, so compile once per reviewed payload and never retry or parallelise it. Always review the exact returned plan before confirming.

1. **Choose the operation and revision.** `list_strategies` (add `includeInactive:true` when preparing a RESTORE) and `get_strategy` return the current `revision`; thread it into the next revisioned call.
2. **Discover the report vocabulary live.** Walk `list_strategy_categories` → `list_strategy_vocabulary` → `get_metric_construction_hints` → `get_strategy_column_contract`, and use `get_strategy_section_template` / `preview_strategy_report`. Do not guess metric, transform, parameter, template, or enabled-timeframe facts — they are server-discovered.
3. **Compile one complete plan.** Call `compile_strategy_plan({ request })` where the nested request is exactly one strict branch plus a bounded `coinSelection`, `intentSummary`, and `assumptions`:
   - **CREATE** supplies the full new strategy.
   - **UPDATE** supplies at least one changed axis and `expectedRevision`.
   - **RESTORE** targets an owned inactive revision (with any repair axes).
4. **Review before confirming.** Inspect the returned `approvedPlan` (complete post-state, proposed revision, diff, bound-agent impact, expiry) and `reviewContext` (column contracts, point-in-time report preview, open positions, quota/name admission). The plan token expires after five minutes; recompile after expiry or drift.
5. **Apply the plan the server already holds.** After explicit user approval, call `apply_strategy_plan({ request: { planToken, confirm: true } })`. **There is no `plan` member** — one is rejected as an unknown key. The server keeps the plan its own compile approved and reads it back, so nothing is copied out of the compile response and nothing can be mistyped or truncated in transit. Forward `planToken` byte-for-byte exactly as received: it is an opaque signed value, never retyped, paraphrased or rebuilt from memory, and a mangled one addresses no approved plan and is refused. `PLAN_APPROVAL_NOT_FOUND` means no approved plan answers to this token — already applied, lapsed, or never issued — and the recovery is to compile again; so is `TOKEN_EXPIRED` once the five minutes run out. Any other refusal (quota, name collision, a bound agent that changed, a moved catalog) **leaves the plan applicable**: clear the cause and confirm again with the same token while it lives. Changed configuration propagates to every bound agent immediately.

   The authored axes — including normalized `sections` and `conditions`, each condition carrying its own required, nullable `verdict` — belong on the **compile** request. Every derived field (`diff`, `viability`, `mismatches`, `signalRules`, `creationSeed`, `proposedRevision`, `bindingImpact`, `authoringCatalogDigest`, `reviewContext`) is re-derived server-side and rejected as an unknown key if resubmitted, and `conditionVerdicts` is rejected with a message naming its replacement — the verdict belongs on the condition.

`update_strategy_signal_rule({ request })` is the thin, focused one-rule edit. In multi-account mode every one of these calls uses the `{ account, request }` sibling envelope.

**Strategy-bound agents.** Bind a strategy to an intelligence agent at creation time — `create_intelligence_agent({ …, strategyId })` (discover `strategyId` via `list_strategies`). Rebinding via `update_intelligence_agent` requires `confirm:true`. There is no direct `create_strategy` operation.

## Capabilities

Tools, prompts, and resources are **discovered live** from the connected server via `tools/list`, `prompts/list`, and `resources/list`. This package intentionally does not copy the server's catalog, formulas, signal IDs, or defaults — inspect the live connection for the authoritative, current surface. Broadly, the server exposes game play (Market Grid), market context, account state, leaderboards, intelligence agents and automation, strategy discovery/authoring, and trading signals/decisions.

## Environment variables

| Variable | Required | Description |
|----------|----------|-------------|
| `BATTLEGRID_API_KEYS` | One of these | Comma-separated API keys for multiple accounts |
| `BATTLEGRID_API_KEY` | One of these | Single API key (fallback if `BATTLEGRID_API_KEYS` not set) |
| `BATTLEGRID_API_URL` | No | Override server URL (default: `https://mcp.battlegrid.trade/mcp`) |

## Rediscovery & versioning

- **The announced contract is relayed, not declared.** The proxy reads the upstream server's identity from the handshake it just completed and re-announces it verbatim to the local client. A local client therefore always reads the contract it will actually reach, and the package version is free to mean only what it should: this proxy's own code. **There is no pairing rule to keep, and no publish-time deploy gate** — a released package makes no claim about the server, so there is no ordering between a release here and a deploy there. This replaces the `MAJOR.MINOR` pairing that held through v11; see [v12](#v12--the-announced-contract-is-read-from-the-server-not-declared-here).
- **Fails closed, never falls back.** If a connected server announced no `serverInfo` — a protocol violation, since it is required in a successful `initialize` result — the proxy refuses to start rather than substituting its own version. There is no honest number to announce in that case, and announcing a dishonest one silently is the failure this design removes.
- **Rediscover after a server cutover.** The proxy resolves its catalog on first use and then caches it for the life of the process; a resolution that *failed* is retried on the next request, but a server deploy is never noticed. Package publication does not refresh it either. Restart/reconnect the proxy and re-run `tools/list`, `prompts/list`, and `resources/list` after the server deploys.
- **Restart after key rotation.** API keys are read once at process startup; rotate a key, then restart the proxy.

| Version | Changes |
|---------|---------|
| 1.x | Single/multi-account stdio proxy, identity discovery, connection retry, capability discovery |
| 2.0.0 | Default `BATTLEGRID_API_URL` moved to the `/mcp` suffix |
| 3.0.0 | Strategy-authoring major: strict `{ account, request }` authoring envelopes with strip-only-account routing, compile → review → apply workflow, strategy-bound agent creation, and removal of the retired `create_strategy` operation |
| 3.0.1 | Docs only — `apply_strategy_plan` now takes `{ plan, planToken, confirm }` instead of `{ approvedPlan, … }`; the server re-derives every planner-derived field and rejects resubmitted ones as unknown keys. No proxy behavior change |
| 4.0.0 | Realigns the package major with the server's MCP contract v4.0.0, which broke on the conditions axis: `apply_strategy_plan` requires `conditions` and `conditionVerdicts` on the plan post-state. No proxy code change — the version is the client-facing signal, and the proxy's handshake carries it |
| 5.0.0 | Pairs with the server's MCP contract v5.0.0, the conditions/verdicts fusion: `conditionVerdicts` is retired and rejected with a message naming its replacement, each condition carries a required nullable `verdict`, precedence is the conditions' declaration order, the advertised authorable verdict domain narrows to `UP`/`DOWN`/`NEITHER`, and the evaluated per-coin verdict is nullable. No proxy code change — the version is the client-facing signal, and the proxy's handshake carries it |
| **5.1.0** | Pairs with the server's MCP contract v5.1.0, which is **additive**: `session-field` joins the prompt-section `kind` union — a section whose rows are not coins, carrying facts about the field as a whole. Nothing is removed and nothing previously accepted is rejected, so a 5.0.x client keeps working for every call it already makes; only a client that switches exhaustively on section `kind` needs a default branch, while one that renders `content` generically needs nothing. No proxy code change — the proxy copies `sections` opaquely and never enumerates kinds |
| 5.2.0 | Server contract v5.2.0, **additive**: four `MetricKey`s join the published catalog vocabulary (`SPOT_CVD`, `PERP_SPOT_FLOW`, `PERP_SPOT_STRENGTH`, `PERP_SPOT_CONFIRMS`), `includePerpSpotFlow` joins the context-source key set as its 23rd member (opt-in — no existing agent's report changes), and `perp-spot-flow` joins the prompt-section `kind` union. Same client impact as 5.1.0: only an exhaustive switch on `kind` needs a default branch. Never published as a package version |
| 6.0.0 | Server contract v6.0.0, **breaking**: `VOLUME_RATIO` is retired and replaced by `RVOL`, and its generated header code moves `vol` → `RVOL` at the same time. `MetricKeySchema` auto-derives from the catalog, so the published enum simply stops accepting the old key — an input-acceptance narrowing, the same shape as the v4 and v5 breaks. Two distinct failure modes: authoring a column with `metric: 'VOLUME_RATIO'` is rejected against the enum, and matching rendered headers on `vol` (or `vol_trend` / `vol_rank_hi`) silently stops matching. No alias survives, deliberately. Every persisted reference was migrated server-side, so only client-side literals need action. Never published as a package version |
| 6.1.0 | Server contract v6.1.0, **additive**: `PlatformSectionDTO` gains `columns` — a platform section's composition in the same wire shape a custom section's columns already travel in, empty for a registry-declared special. **Not a copy source**: six platform columns pair a metric with `classifyState`, a deliberate composability exclusion that authoring rejects at construction, so a client that round-trips these into a custom section is refused — correctly. Never published as a package version |
| 7.0.0 | Server contract v7.0.0, **breaking**: the strategy-report preview limits became discoverable and the bare token count was removed. `preview_strategy_report` and `compile_strategy_plan` no longer carry `estimatedTokenCount`; the same number sits one level deeper as `budgetUsage.estimatedTokens.used`, paired with the `cap` that governs it — so a client reading the count changes one path and gains the ceiling it was never told. `tokenCountModel` is unchanged. Discovery adds `budgets.estimatedTokens` and a `previewExecutionLimits` object (serialized-result byte cap, preview deadline), both published cap-only. Never published as a package version |
| 8.0.0 | Server contract v8.0.0, **breaking**, on two axes. `BB_WIDTH × rank` is no longer authorable: `BBwidth` is a price-unit spread that falsely declared `percent`, which was the only thing admitting it to exchange-wide ranking, so it re-declares `signedPrice` and leaves the ranked contract — `{metric: 'BB_WIDTH', transformId: 'rank'}` is rejected and `BBwidth_rank_lo` stops matching, with `BB_WIDTH_PCT` (`bbWidthPct`) shipping as the comparable replacement. Separately the published `unit` enum **widens** with `ratio` and `fraction`, and `RVOL`, `BUY_PRESSURE`, `BB_PCT_B` emit them instead of `percent` — a break for a client holding its own closed copy of that enum, which is the opposite direction from an input narrowing. Values are not rescaled: `buyPres` and `pctB` stay 0–1. Never published as a package version |
| **9.0.0** | Server contract v9.0.0, **breaking**: `compile_strategy_plan`'s `approvedPlan.mismatches` changes on both axes. Both report-coverage codes are renamed off "module" (`ACTIVE_SIGNAL_MODULE_NOT_IN_REPORT` → `ACTIVE_SIGNAL_DATA_NOT_IN_REPORT`, `REPORT_MODULE_SIGNAL_OFF` → `REPORT_DATA_SIGNAL_OFF`), and each mismatch carries a required `data: CoverageDatum[]` — the `(metric, rung)` pairs it is about. A client switching exhaustively on the old code strings stops matching. Coverage is now decided by whether the report renders a signal's declared metrics at the rung that signal reads, so expect warnings never seen before and the disappearance of warnings no composition could clear; mismatches stay advisory and non-blocking. Never published as a package version |
| 10.0.0 | Server contract v10.0.0, **breaking**: challenge participation stops being a declared setting anywhere and becomes identical to effective trade permission, resolved per coin. `create_agent`/`update_agent` stop accepting `arenaChallengeEnabled`, and a deployment policy's slot and per-coin rule shapes stop accepting `challengeEnabled` — all four are `.strict()`, so a client still sending them is rejected rather than ignored, the same input-acceptance narrowing that made v4, v5, v6 and v8 majors. `IntelligenceAgentDTO` drops `arenaChallengeEnabled`; `ResolvedSlotRulesDTO.challengeEnabled` stays and keeps its shape, now derived server-side with the same value and provenance as `tradingEnabled`. The resolution DTO also gains `agentTradingMode` — the preview previously reported trade rules for an agent whose account-level trading was off. Never published as a package version |
| **11.0.0** | Server contract v11.0.0, **breaking**: a catalogued numeric output's `range` changes shape — the closed positional tuple `[min, max]` becomes the half-open object `{ min: number; max?: number }`, travelling through `ScalarSchema` into `list_strategy_vocabulary`, `query_report_catalog` and `get_metric_construction_hints`. A client reading `range[0]`/`range[1]` gets `undefined` **with no error**, which is the silent failure mode a version exists to prevent. The driver: the tuple could not state the truth about the volume/trade-count family — non-negative and unbounded above — and `Infinity` serializes to `null` on the wire; those six metrics now declare `{ min: 0 }` and no longer offer `far`/`near` rank orderings, which on a non-negative value are a synonym pair. Additive alongside it: the transform vocabulary grows 15 → 17 (`efficiency`, `maxShare`), both joining the chain-outer enum served as `chainSuccessors`. **The last version published under the pairing rule.** No proxy code change — the version was the client-facing signal, and the proxy's handshake carried it |
| 12.0.0 | Server contract v12.0.0, **breaking**: radar's wall-clock condition changes shape (`unify-deployment-hours-as-sets`) |
| 12.1.0 | Server contract v12.1.0, **additive**: `SPOT_CLOSE_CB` / `SPOT_CLOSE_BN` join the metric catalog |
| 13.0.0 | Server contract v13.0.0, **breaking**: scalar families become placeable modules; six scalar headers leave the `session-field` key |
| 13.1.0 | Server contract v13.1.0, **additive**: four owner-scoped read tools join the catalog, closing web-client parity gaps |
| 14.0.0 | Server contract v14.0.0, **breaking**: the agent's ATR timeframe axis is removed — ATR samples on the strategy timeframe |
| 15.0.0 | Server contract v15.0.0, **breaking**: the trade-level policy moves off the agent onto the strategy |
| 16.0.0 | Server contract v16.0.0, **breaking**: a strategy condition gains a REQUIRED `required` boolean; omitting it is rejected |
| 17.1.0 | Server contract v17.1.0, **additive**: `get_signal_log` gains `log.conditionEvaluation` |
| 17.2.0 | Server contract v17.2.0, **additive**: position rows gain `breakEvenStatus` / `trailingStatus` |
| 18.0.0 | Server contract v18.0.0, **breaking**: `get_radar_activity` gains an `EVALUATION_OUTCOME` member and two keys on every member |
| 18.1.0 | Server contract v18.1.0, **additive**: protection geometry behind the v17.2.0 verdicts |
| 18.2.0 | Server contract v18.2.0, **additive**: `TradingPipelineGateStage` gains `EVALUATION`; reason codes gain `EVALUATION_FAULTED` |
| 18.3.0 | Server contract v18.3.0, **additive**: the platform maintenance pause reaches the radar surfaces |
| 18.4.0 | Server contract v18.4.0, **additive**: `list_gate_blocks` gains `summary` groups |
| 19.0.0 | Server contract v19.0.0, **breaking**: the strategy regime timeframe becomes derived and read-only |
| 19.1.0 | Server contract v19.1.0, **additive**: Standing Orders markers become authorable and resolvable before save |
| 19.2.0 | Server contract v19.2.0, **additive**: `get_account_state` gains account-identity fields |
| 19.3.0 | Server contract v19.3.0, **additive**: `QualificationGateCode` gains `REQUIRED_CONDITION_FALSE` |
| 19.4.0 | Server contract v19.4.0, **additive**: reason codes gain `OPEN_POSITION_CHECK_UNAVAILABLE` |
| 20.0.0 | Server contract v20.0.0, **breaking**: the `signal_pipeline` decision becomes a union discriminated on the verdict; `GATED` joins it |
| 21.0.0 | Server contract v21.0.0, **breaking**: `get_radar_activity` gains `blockReasonCode` on every member |
| 22.0.0 | Server contract v22.0.0, **breaking**: `positionManagement` gains REQUIRED `trailingTriggerR` |
| 23.0.0 | Server contract v23.0.0, **breaking**: `tradingConfig.tradingMode` is removed — trading is scoped per deployment |
| 24.0.0 | Server contract v24.0.0, **breaking**: `positionManagement` leaves the agent for the strategy |
| 25.0.0 | Server contract v25.0.0, **breaking**: the arena stops granting trade authority; slot trade fields are rejected |
| 26.0.0 | Server contract v26.0.0, **breaking**: both entry-lifecycle guards leave the agent for platform config |
| 27.0.0 | Server contract v27.0.0, **breaking**: the trade-defaults catalog drops four unauthorable seeds |
| 27.1.0 | Server contract v27.1.0, **additive**: `compile_strategy_plan` accepts the twelve exit-policy keys |
| 28.0.0 | Server contract v28.0.0, **breaking**: grid-confidence and trade-conviction bars become deployment declarations |
| 29.0.0 | Server contract v29.0.0, **breaking**: `get_radar_activity` gains an `EDGE_REARM` variant and six keys on every member |
| **30.0.0** | Server contract v30.0.0, **breaking**: the stop-loss ceiling changes unit (`maxStopLossPct` → `maxStopLossAtrMultiple`, range `(0,3]`), a `floor < ceiling` rule is enforced, and **the position-size presets change meaning without changing shape** — they denote a risk budget, not a share of the order |
| 23.0.0 | Server contract v23.0.0, **breaking**: the agent-level trading mode is retired. `create_agent`/`update_agent` stop accepting `tradingConfig.tradingMode` on the shared `.strict()` `TradingConfigSchema`, so a client still sending it is rejected rather than ignored — the same input-acceptance narrowing that made v4, v5, v6, v8 and v10 majors. On the read side `AgentTradingConfigDTO` drops `tradingMode` on every agent-returning tool, and so do the agents-hub permission envelope, the explorer entry and both public-profile shapes; `DeploymentResolvedResolutionDTO` drops `agentTradingMode`, the field v10.0.0 added, because with no account layer to overlay the resolved `tradingEnabled` is the whole answer. Trading on/off is now scoped per deployment (radar policy `enabled`, arena slot `tradingEnabled`, per-coin `tradeEnabled`) and a newly authored arena slot starts with trading **off**; approval-before-execution is the conversational surface's own contract, so `accept_entry_decision` / `cancel_entry_decision` / `list_pending_approvals` are unchanged on the wire but now carry conversational proposals exclusively — a deployed agent never queues for approval. Never published as a package version |
| 24.0.0 | Server contract v24.0.0, **breaking**: the post-entry exit policy moves from the agent to the strategy. `create_agent`/`update_agent` stop accepting `tradingConfig.positionManagement` on the shared `.strict()` `TradingConfigSchema`, so a client still sending it is rejected rather than ignored — the same input-acceptance narrowing that made v4, v5, v6, v8, v10 and v23 majors. On the read side `AgentTradingConfigDTO` drops the nested block on every agent-returning tool and the explorer trading spec drops it too; `get_trading_config_catalog` drops `positionManagementPresets` and the `defaultPositionMgmt*` trading defaults. The pistol-preset ladder (COLT / WEBLEY / BERETTA / LUGER / WALTHER) is **retired, not renamed** — once the values live on the strategy, the strategy is the named bundle. Additive on the authoring surface in the same bump: `compile_strategy_plan`/`apply_strategy_plan` post-state gains the twelve authored keys beside the trade-level trio, and the plan diff gains a `positionManagement` axis. Behaviourally the umbrella `enabled` flag is **deleted** rather than moved: each mechanism toggle is the whole truth for that mechanism, so a client can no longer express "trailing on, management off". Never published as a package version |
| 26.0.0 | Server contract v26.0.0, **breaking**: both entry-lifecycle guards stop being agent configuration. `create_agent`/`update_agent` stop accepting `tradingConfig.signalTimeoutMinutes` and `tradingConfig.maxEntryDeviationAtrMultiple` on the shared `.strict()` `TradingConfigSchema`, so a client still sending either is rejected rather than ignored — the same input-acceptance narrowing that made v4, v5, v6, v8, v10, v23 and v24 majors. Neither has a replacement key: one `platform_config` value governs the entry-price drift budget for every decision (read at evaluation time, so an admin edit applies to the next evaluation), and one governs how long an entry may stay unfilled (snapshotted onto the position at creation, so an edit can never cancel an order already resting on the book). On the read side `AgentTradingConfigDTO` drops both fields on every agent-returning tool, and so do the explorer trading spec and the agent-review payload; `get_trading_config_catalog` drops `defaultSignalTimeoutMinutes` and the `minimum_`/`maximum_maxEntryDeviationAtrMultiple` bound pair, while `defaultMaxEntryDeviationAtrMultiple` and `defaultTtlMinutes` stay and become the values that actually govern. Behaviourally a conversational entry and an autonomous entry on the same setup now receive the **identical** unfilled lifetime — the mode-selecting fallback that chose between a per-agent timeout and a hardcoded 15-minute resting window is gone, and the three-way timeout enum with it. Never published as a package version |
| **31.0.0** | **Proxy change, and the end of the pairing rule.** The version announced downstream is now read from the upstream handshake at connect time and relayed verbatim, instead of being a constant compiled into this package. A local client reads the contract it will actually reach, on every connection, with no release involved. Breaking because the package number now means something different — this proxy's own code, not the server's contract — so `npm view` and the handshake legitimately differ, and code keyed to them being equal is wrong. Retired with it: the publish-time deploy gate (`scripts/assert-deployed-contract.mjs`) and the `MAJOR.MINOR` pairing rule, both of which existed only because the two numbers could disagree. Fails closed if a connected server announces no `serverInfo` rather than substituting its own version. Contract moves no longer produce a release here |

## Maintainer release procedure

> [`.github/workflows/publish.yml`](.github/workflows/publish.yml) is the executable release authority. If this recipe and the workflow diverge, correct them together before merging a version change.

Publishing runs only in GitHub-hosted Actions. Never run `npm publish` from the BattleGrid application VM or a maintainer workstation.

**A version change on `main` is the release.** Merge a pull request that changes `package.json`'s version and the workflow does the rest: it confirms that version is not already on the registry, verifies every value expressing it agrees, tests, builds, packs, publishes with npm provenance, and tags what shipped. There is no manual tag step — the tag is an output of a successful publish, not its prerequisite.

**Release whenever this package's code is ready.** Since v12 the package makes no claim about the server's contract, so there is no deploy to sequence against and no gate asserting one. Publishing before, during, or after a server deploy is equally correct.

That inversion is deliberate. Publication used to be triggered by a tag, which meant a version bump with no tag published nothing **and reported nothing** — how `5.1.0` came to be declared in this repository and absent from the registry, caught by no check at all.

### Release environments and prerequisites

| Responsibility | Environment |
|---|---|
| Confirm npm publishing trust | npmjs.com package settings |
| Prepare and merge the version change | Pull request against `main` |
| Check, build, publish, and tag | GitHub-hosted `ubuntu-latest`, Node 24 |
| Verify registry publication | Any shell |

**The workflow needs no BattleGrid credential.** It never contacts the BattleGrid server at all. The deploy gate that used to (reading `GET /mcp/version`, unauthenticated by design) was retired in v12 along with the pairing rule that motivated it. Its no-credential property is worth keeping in mind if a future check ever needs the contract version: every BattleGrid MCP API key carries `mcp:wager` and there is no read-only variant, so a credentialed check would mean this workflow holding authority to submit wagers and close live positions in order to read a version number. `GET /mcp/version` exists precisely so that trade never has to be made.

Also confirm npm's Trusted Publisher for `@battlegrid/mcp-server` is GitHub Actions with organization `playbattlegrid`, repository `battlegrid-mcp`, workflow filename `publish.yml`, no environment name, and `npm publish` allowed. The workflow uses short-lived OIDC credentials; do not add a long-lived `NPM_TOKEN`.

### Preparing the version change

- **Version this package's own code, and nothing else.** Since v12 the number describes this proxy's build — a fix here, a dependency bump, a documentation correction — and makes no statement about the server. **Do not move it because the server's contract moved**; that used to be the whole job and is now a category error. Ordinary semver against the proxy's own surface: MAJOR for a break in how the proxy behaves or what its number means, MINOR for proxy features, PATCH for fixes and docs.
- **Move all three values together** — `package.json`, both `package-lock.json` version fields (the root `version` and the self-referencing `packages[""].version`), and `PACKAGE_VERSION` in `src/index.ts`. The workflow compares all of them and fails closed on any disagreement.
- **No deploy to wait for.** A release here is independent of the server's deploy schedule in both directions.

### What a server contract move needs from this package

**Nothing.** That is the point of v12. When the server's contract moves, connected proxies announce the new version on their next connection, with no publish, no version bump, and no coordination.

Two things do still need doing, neither of them a release:

- **Reconnect** to pick up the new contract — the announcement is read once from the handshake at startup, and the capability snapshot is resolved on first use and cached thereafter (see [Rediscovery & versioning](#rediscovery--versioning)).
- **Document the break** where the contract is documented, in `battlegrid-app`. Contract breaking-change notes are no longer keyed to package versions in this README, because a contract move is no longer a release here.

### Verify publication

The workflow publishes and tags on its own; these confirm what landed.

```bash
release_version="$(node -p "require('./package.json').version")"

npm view "@battlegrid/mcp-server@${release_version}" version dist.integrity \
  --json --registry=https://registry.npmjs.org/

npm view @battlegrid/mcp-server dist-tags \
  --json --registry=https://registry.npmjs.org/

npm view "@battlegrid/mcp-server@${release_version}" dist.attestations \
  --json --registry=https://registry.npmjs.org/
```

Require the exact version, `latest` pointing at that version, and a provenance attestation. Confirm the workflow created `mcp-server@${release_version}` and that `gitHead` on the published version is the merge commit. Restart/reconnect running proxies and rediscover `tools/list`, `prompts/list`, and `resources/list`; publication alone does not refresh their startup cache.

There is no separate registry-reconciliation step to remember. "Is this version already published?" is the workflow's own first question — it decides whether the run publishes at all — so a bump can no longer sit in the repository unpublished and unreported the way `5.1.0` did.

**The server-side release canary is not evidence about this package.** `battlegrid-app`'s `server/scripts/release-canary-mcp.ts` connects to the deployed endpoint and compares `client.getServerVersion()` against the server's own imported `MCP_CONTRACT_VERSION` — **both sides are server-side, and it never queries npm.** It passes with this package at any version, including one that was never published. Package-side evidence is exactly two things: the workflow's version-integrity gate, and the registry checks above with `gitHead` matching the release commit.

**A reconnect no longer proves which package version is running**, and this is the one verification v12 took away rather than improved. The stdio handshake now shows `battlegrid@<contract>` — the *server's* number — so it is identical whether the local proxy is 12.0.0 or a stale 11.0.0 from a cached `npx`. Read `PACKAGE_VERSION` from the installed `dist/index.js`, or the trailing `proxy <version>` field in the startup stderr line, to confirm which build is running.

If a run fails, inspect it before taking action. An `ENEEDAUTH` failure means the npm Trusted Publisher fields do not match the workflow — fix the publisher configuration and re-run the job; nothing was published and no tag was created, so there is nothing to move or reuse. Never mutate a published release with `npm audit fix`; dependency remediation goes through a new reviewed commit and version.

## Skills

Install the BattleGrid skills for AI agent instructions:

```bash
npx skills add playbattlegrid/battlegrid-mcp
```

Nine skills ship from this repo, all inside the npm tarball (`SKILL.md`, `skills/`).

**`battlegrid`** (repo root) is the connection skill and is authored here: how to connect, the
`{ account, request }` envelope, the two scopes, and where to go for everything else.

The nine `skills/battlegrid-*` are **exported from BattleGrid's server repository** — they are the
same instructions BattleGrid's own in-app Commander runs on, which is why they name the same tools
you reach over MCP:

| Skill | Teaches |
|---|---|
| `battlegrid-agent-management` | Commission and govern intelligence agents: interview and create one against a committed strategy and an approved model, change configuration and risk limits, rebind, halt, resume, archive, and act on live positions |
| `battlegrid-arena-play` | Enter Market Grid sessions: find an open session, read its coin pool and live market context, compose a grid with real per-coin reasoning or have an agent generate it, submit, then read results and the reasoning journal |
| `battlegrid-market-analysis` | Read the current crypto market — regime, funding and open interest, leaders and laggards, a deep-dive on any named coin — and close with the levels worth watching |
| `battlegrid-radar-deployment` | Put agents on standing duty: per-coin Radar policies that fire on confirmed regime flips, and per-preset Arena deployment policies, previewed before they are written and un-deployed with the blast radius stated |
| `battlegrid-strategy-authoring` | Build a strategy from a plain-English idea: gather evidence, lock the spec, compile against the platform grammar, review exactly what will run, apply only on confirmation. Also fork, tune, restore, archive, preview |
| `battlegrid-strategy-doctor` | Diagnose an agent that is not doing what was expected — why it has not traded, why it stopped, whether it is healthy — from typed fields, then rank the fixes with the exact lever each needs |
| `battlegrid-strategy-examples` | Full-surface composition patterns: custom report sections and header grammar, benchmark sections, condition trees with verdicts and enforcement gates, tiered signal weights and the aggregate gate math, routing gates, ATR trade levels, position management, plus validated desk-grade playbooks and TradingView process ports |
| `battlegrid-trade-analysis` | Read your own trading position: where the money is, whether each agent is doing its job, what is open and how close it sits to its protections, and whether the automation is actually running |
| `battlegrid-trade-proposal` | Find and stage a trade for one of your agents: check what is already held, scan every active coin against the agent's own gates, propose on one through the agent's own conversational turn, present the outcome with its conviction, and approve or decline only on your word |

> **`skills/battlegrid-*` is generated — do not edit it here.** It is written by
> `server/scripts/export-mcp-skills.mjs` in `playbattlegrid/battlegrid-app` and arrives by pull
> request; `skills/EXPORT.json` records a hash per file and `src/__tests__/skill-provenance.test.ts`
> fails CI on a hand edit. Change the skill upstream and let the export lane bring it here.

## License

[MIT](LICENSE)