diff --git a/CHANGELOG.md b/CHANGELOG.md index a866efcd..82776267 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,101 @@ All notable changes to kalshi-sdk will be documented in this file. +## 2.6.0 — 2026-05-22 + +Post-v2.5 independent reviewer audit closure (`#273` follow-on). 7 issues +(`#295`–`#301`) identified by a fresh 7-agent parallel review of v2.5.0 across +security, HTTP transport, WebSocket reliability, models/types, REST resources, +performance, and docs/testing. Executed across 3 sequential waves (W0 docs, +W1 money/correctness, W2 polish) in disjoint git worktrees. 7 PRs merged; +main is mypy --strict clean, ruff clean, 2780+ unit tests passing. + +### Breaking changes + +Two behavioral fences; both surface bugs that were already wrong. + +- **`int` request fields reject `bool`** (`#295`). Per-field `StrictInt` + annotation on every money-routing / counting integer of every Request + model: `subaccount`, `exchange_index`, `expiration_ts`, `reduce_by`, + `reduce_to`, `contracts_limit`, `contracts`, `from_subaccount`, + `to_subaccount`, `amount_cents`, `subaccount_number` across V1 + V2. + `bool` is an `int` subclass, so a caller passing `True`/`False` used to + silently route to subaccount 1 / transfer 1 cent / decrease by 1 contract + with no error. Now raises `ValidationError` at construction. The existing + `buy_max_cost` validator (`#243`) is unchanged. New `kalshi.StrictInt` + alias is exported for downstream models. +- **`KALSHI-ACCESS-*` in `extra_headers` is rejected** (`#298`). Both + `KalshiClient(..., config=KalshiConfig(extra_headers=...))` at + construction time and per-request `extra_headers=` kwargs now raise + `ValueError` if any key (case-insensitive) starts with `kalshi-access-`. + Previously a caller-supplied `'kalshi-access-key'` (lowercase) co-existed + with the SDK-signed `KALSHI-ACCESS-KEY` and httpx shipped both raw header + lines — a forge surface even though the documented contract promises + auth headers are SDK-managed. + +### Critical (money-risk fixes) + +- **`int` request fields reject `bool`** (`#295`, see Breaking). +- **Auth-header forge surface closed** (`#298`, see Breaking). Companion fix: + `_post(json=...)` / `_put(json=...)` / `_delete_with_body(json=...)` and + their async mirrors now pin `Content-Type: application/json` explicitly, + preventing a caller-supplied `'content-type': 'text/plain'` in + `extra_headers` from causing httpx to ship a JSON body labelled as + plain text. +- **WebSocket session re-entry is rejected** (`#297`). `KalshiWebSocket._start()` + used to silently rebuild every manager on nested or re-used `connect()`, + orphaning the outer session's subscriptions and recv task with no error. + Now raises `RuntimeError` with a clear message. `_stop()` clears the + manager refs after teardown so the same instance can be cleanly reused + for a fresh `connect()` once the prior session exits. A partial connect + failure (auth/network) also resets state cleanly via a `BaseException` + cleanup block, so a failed connect no longer permanently bricks the + instance. + +### High-impact correctness + +- **`KalshiConfig.extra_headers` validated at construction** (`#298`). Closes + the construction-time bypass that survived the per-request guard. +- **Case-insensitive header merge** (`#298`). New `_ci_merge` ensures a + caller-supplied `'x-foo'` and SDK-set `'X-Foo'` collapse to one wire + entry rather than co-existing. +- **Public `OrderbookManager.apply_snapshot()` keeps no-aliasing contract** + (`#296`). Snapshot/delta input messages remain safe to reuse after a + public `apply_snapshot()` call — the manager defensively copies the + adopted dicts. The recv loop continues to skip the copy via + `_apply_snapshot_inplace` for the hot-path perf win (#263). + +### Performance + +- **Orderbook snapshot adoption restored to identity** (`#296`). The + `dict(msg.msg.yes)` / `dict(msg.msg.no)` wrappers in + `_apply_snapshot_inplace` were silently nullifying the ~5x speedup + CHANGELOG #263 advertises. For a 200-level book that's 400 needless + re-hashes/reallocs per snapshot on the recv hot path. Wrappers dropped; + identity adoption restored on the bypass path. + +### Polish + +- **`Sync/AsyncTransport.close()` explicitly idempotent** (`#301`). New + `_closed` flag matches the `KalshiAuth` / `KalshiClient` pattern; second + and subsequent `close()` calls are no-ops. Documents the threading scope + honestly: sync is sequential-safe (worst case: one redundant + `httpx.Client.close()`, itself idempotent), async is fully race-free + under cooperative scheduling. +- **`docs/resources/orders.md`**: removed stale `# ActionLiteral, defaults to + "buy"` inline comment that would have re-introduced the `#242` footgun + for readers (`#299`). +- **`docs/configuration.md`**: reference table now lists `total_timeout`, + `ws_ping_interval`, `ws_close_timeout`, and `allow_unknown_host`; URL + validation prose updated for the v2.5 default-reject behavior (`#300`). + +### Additive + +- **`kalshi.StrictInt`** — new public type alias for downstream models that + want the same `bool`-rejection guard (`#295`). +- **`kalshi/_constants.py`** (internal) — holds `AUTH_HEADER_PREFIX` to + eliminate drift between `_base_client.py` and `config.py` (`#298`). + ## 2.5.0 — 2026-05-21 Post-v2.4 multi-reviewer audit closure (`#273`). 34 issues across security, diff --git a/ROADMAP.md b/ROADMAP.md index 628ffef1..1db02d75 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,6 +4,20 @@ See `CHANGELOG.md` for full release history. +- **v2.6.0 (2026-05-22)** — post-v2.5 independent reviewer audit closure + (`#273` follow-on, 7 issues `#295`–`#301`). Two breaking changes folded + in: `int` request fields reject `bool` across V1+V2 via new + `kalshi.StrictInt` (`#295`); `KALSHI-ACCESS-*` in `extra_headers` is + rejected at both construction and per-request (`#298`). Critical fixes: + WS session re-entry guard with state reset on partial connect failure + (`#297`), case-insensitive transport header merge + explicit + `Content-Type` pin on JSON body helpers (`#298`). Performance: orderbook + snapshot identity adoption restored on the recv-loop bypass (`#296`), + with public `apply_snapshot` keeping its defensive copy. Polish: + `Sync/AsyncTransport.close()` idempotent (`#301`), docs corrections + (`#299`, `#300`). Executed across 3 sequential waves (W0 docs, W1 + money/correctness, W2 polish) — 7 PRs merged. + - **v2.5.0 (2026-05-21)** — post-v2.4 multi-reviewer SDK audit closure (#273, 34 issues across 7 surfaces). Critical fixes: WS seq watermark rolls back on validation failure (#241), REST/WS split-environment diff --git a/docs/migration.md b/docs/migration.md index 7a1f378c..5856fb43 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,5 +1,104 @@ # Migration +## v2.5 → v2.6 + +v2.6 ships two behavioral fences, both surface bugs that were already wrong: +``bool`` no longer slips through on integer request fields (`#295`), and +``extra_headers`` cannot carry ``KALSHI-ACCESS-*`` keys (`#298`). Wire +format is unchanged. + +### Breaking: `int` request fields reject `bool` + +Per `#295`, every integer field on every Request model that lacks a +dedicated validator now rejects ``bool`` at construction. ``bool`` is an +``int`` subclass, so a caller passing ``True``/``False`` silently became +``1``/``0`` and money-routing fields (``subaccount``, ``from_subaccount``, +``amount_cents``, ``count``, etc.) silently misrouted. The fix mirrors +``CreateOrderRequest.buy_max_cost`` (`#243`) across all request models on +both V1 and V2 surfaces: + +```python +# v2.5 (silent True → 1) +ApplySubaccountTransferRequest( + client_transfer_id=uuid4(), + from_subaccount=True, # → 1, transfer from subaccount 1 + to_subaccount=True, # → 1, transfer TO subaccount 1 + amount_cents=True, # → 1, one cent +) + +# v2.6 (raises ValidationError on each bool) +ApplySubaccountTransferRequest( + client_transfer_id=uuid4(), + from_subaccount=True, # ➜ ValidationError: bool is not a valid int here +) +``` + +A new public alias ``kalshi.StrictInt = Annotated[int, BeforeValidator(...)]`` +is exported for downstream models that want the same guard. + +### Breaking: `KALSHI-ACCESS-*` rejected in `extra_headers` + +Per `#298`, the SDK promised in docs that auth headers could not be forged +via ``extra_headers``. That promise relied on a case-sensitive Python dict +merge: a caller-supplied lowercase ``"kalshi-access-key"`` co-existed with +the SDK-signed uppercase ``"KALSHI-ACCESS-KEY"`` and httpx shipped both raw +header lines on the wire — a forge surface even though the documented +contract said otherwise. Now both the per-request ``extra_headers`` kwarg +and ``KalshiConfig(extra_headers=...)`` reject any key (case-insensitive) +that starts with ``kalshi-access-`` at the construction boundary: + +```python +# v2.5 (silently shipped two KALSHI-ACCESS-KEY header lines) +client.markets.list( + extra_headers={"kalshi-access-key": "forged"}, +) + +# v2.6 (raises ValueError naming the leaked keys) +client.markets.list( + extra_headers={"kalshi-access-key": "forged"}, +) +# ValueError: extra_headers must not include KALSHI-ACCESS-* keys ... + +# Same rejection now fires at construction: +KalshiConfig(extra_headers={"kalshi-access-key": "x"}) +# ValueError +``` + +Companion behavior: `_post(json=...)` / `_put(json=...)` / +`_delete_with_body(json=...)` (and async mirrors) now pin +``Content-Type: application/json`` explicitly. A caller-supplied +``extra_headers={"content-type": "text/plain"}`` previously suppressed +httpx's implicit inference and shipped a JSON body labelled as plain +text; the explicit pin prevents that. Most callers are unaffected. + +### Behavioral note: raw `subscribe_orderbook_delta` consumers + +Per `#296`, `OrderbookManager._apply_snapshot_inplace` now adopts +``msg.msg.yes`` / ``.no`` by identity for the recv-loop hot path. Raw +consumers of ``subscribe_orderbook_delta`` who receive an +``OrderbookSnapshotMessage`` and hold a reference to ``msg.msg.yes`` / +``.no`` will observe future delta mutations through that reference: + +```python +# v2.6 raw subscriber +async for msg in stream: + if isinstance(msg, OrderbookSnapshotMessage): + held = msg.msg.yes # ← LIVE dict, mutated by next delta +``` + +The high-level ``client.orderbook(ticker)`` / ``subscribe_book(ticker)`` +iterators are unaffected — both materialize fresh ``Orderbook`` instances +that are safe to retain. The public ``OrderbookManager.apply_snapshot()`` +is also unaffected: it defensively copies after the inplace adoption so +the caller-supplied ``msg`` keeps its original dicts. If you need an +immutable snapshot view from the raw stream, copy explicitly: + +```python +async for msg in stream: + if isinstance(msg, OrderbookSnapshotMessage): + immutable_yes = dict(msg.msg.yes) +``` + ## v2.4 → v2.5 v2.5 ships two user-visible breaking changes — both surface bugs that diff --git a/kalshi/__init__.py b/kalshi/__init__.py index af3bf7cf..3822cea1 100644 --- a/kalshi/__init__.py +++ b/kalshi/__init__.py @@ -337,4 +337,4 @@ "Withdrawal", ] -__version__ = "2.5.0" +__version__ = "2.6.0" diff --git a/pyproject.toml b/pyproject.toml index ab12c29b..24bf3e4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kalshi-sdk" -version = "2.5.0" +version = "2.6.0" description = "A professional Python SDK for the Kalshi prediction markets API" readme = "README.md" license = { text = "MIT" }