All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[0.8.0] - 2026-08-25
Fixed — documentation described a different library than the one that ships
A full accuracy pass over the consumer-facing docs. The reference layer
(@doc/@spec, telemetry and Config tables) was already accurate; every
defect below was in copy-pasteable example code, so following the old docs
produced runtime errors rather than merely misleading prose.
Client.send_message/2takes an encodedbinary(), not a map. Twelve call sites acrossREADME.md,USAGE_RULES.mdand the guides passed raw maps. They now encode withJason.encode!/1. The inbound direction is unchanged: text frames are decoded for you, so handlers still receive maps.heartbeat_intervalwas never a connect option. The knob isheartbeat_config(default:disabled); the documentedheartbeat_interval:had no effect. Corrected inREADME.mdandUSAGE_RULES.md, along with the invented:custom/:standardheartbeat types.- Supervised start paths need
:handlerto deliver unsolicited frames. OnlyClient.connect/2installs a parent-forwarding handler (viaClient.CallFacade.with_default_handler/2). Other start paths use a discard handler by default; internal heartbeat and pending JSON-RPC response handling still work, but user-deliverable frames are dropped unless a handler is supplied. - Frames matching
%{"method" => "heartbeat"}never reach the user handler — documented indocs/guides/building_adapters.md, which previously implied all text frames are delivered. docs/gun_integration.mddocumented the pre-decomposition monolith and mis-describedawait_transport/2's monitor handling;docs/Examples.mdclaimed the Deribit examples had moved out of this repo (a reverted decision) and linked to paths that 404 on hexdocs.- Removed fabricated benchmark figures and a contradictory
16-24-vs-8 bytes/sample claim; Deribit rate limits now cite
docs.deribit.com. MessageHandler.default_handler/1documented as accepting and discarding — it never logged.Logger.warn(removed from Elixir) replaced withLogger.warning.SECURITY.mdno longer claims only 0.4.x is supported.- Doctest corrections in
Recorder(wrong JSON key order and a non-existent microsecond padding) andLatencyStats.percentile/2(percentile(1..100, 50)returns51, not50).
Changed — self-describing API
ZenWebsocket.describe/0now omits the four Client-owned internal managers, whiledescribe(:client)includes the supervisedstart_link/2andchild_spec/1entry points. Wire-derived inputs and opaque Client-owned state are marked as non-caller-supplied:exchange_data; caller-created rate-limit requests remain ordinary:valueinputs.mix zen_websocket.validate_usagederives the allowed Client calls from the same Descripex declarations, eliminating a second hand-maintained API list.
Added
ZenWebsocket.ConnectionRegistryis now annotated withDescripex, so it appears inZenWebsocket.describe/0output with per-function metadata. It was the one module listed in theDiscoverablemanifest without annotations.defrpcnow emits an@specfor each generated function.- 16 doctests are now executed by the suite (
ZenWebsocketTest). Previously authored doctests inErrorHandler,JsonRpc,LatencyStats,RecorderandReconnectionwere never run, which is why the errors above survived. CHANGELOG-archive.md— releases 0.1.1 through 0.4.3 moved out ofCHANGELOG.mdand shipped as a separate documentation extra. Entries whose provenance is uneven are marked as such, and a missing0.1.5entry was reconstructed.
0.7.1 - 2026-08-22
Changed — descripex bound
- Runtime requirement widened
{:descripex, "~> 0.12.0"}→{:descripex, "~> 0.12"}so a descripex minor no longer forces a zen_websocket release. The committedmix.lockstill pins the resolved version; a new descripex lands only through a deliberatemix deps.update. - Resolved
descripex 0.13.0in the lockfile. 0.13.0 addstypeless_params/1and folds more@specshapes into JSON Schema; it does not changejson_spec ~> 1.1or zen_websocket'suse Descripexcall sites.
0.7.0 - 2026-08-21
Removed
- BREAKING — RateLimiter queue telemetry:
[:zen_websocket, :rate_limiter, :queue],[:zen_websocket, :rate_limiter, :queue_full], and[:zen_websocket, :rate_limiter, :pressure]are no longer emitted. Remove handlers attached to those events; instrument the caller's{:error, :rate_limited}branch for rejection or pressure signals, while retaining:consumeand:refillhandlers for successful token use and refills. - BREAKING — RateLimiter queue configuration:
:max_queue_sizeand@default_max_queue_sizewere removed. Remove that key from limiter configuration and bound any application-owned retry queue where requests are actually retained. - BREAKING — the
ZenWebsocket.RateLimiterstate/0type: the public type was removed with the internal queue state. Replace references to it with an application-owned status-map type (ormap()); useZenWebsocket.RateLimiter.config/0forinit/2input. - BREAKING —
mix stability_test: the shippedMix.Tasks.StabilityTesttask and its guide were removed. The task was part of the package because the package definition ships the completelibtree. Replace scripts that invoke it with the relevant test command, such asmix test --only external_network, or an application-owned soak test. - BREAKING —
ZenWebsocket.Clientreconnect_opts_from_state/1: this function was removed from theZenWebsocket.Clientsurface; it was not made private. Code that still needs this internal-state conversion can call the publicZenWebsocket.Client.Reconnect.reconnect_opts_from_state/1function.
Changed
- BREAKING — RateLimiter is an allow/deny gate, not a request queue.
consume/2now returns only:ok | {:error, :rate_limited}and never retains a rejected request;{:error, :queue_full}is gone. On:rate_limited, keep and retry the request in caller-owned code.status/1retainsqueue_size,pressure_level, andsuggested_delay_msonly as neutral compatibility fields (0,:none, and0). Token consumption and refill now use compare-and-swap so concurrent refills cannot restore spent tokens. - BREAKING —
Client.connect/2failure term and latency. In 0.6.1 a third:await_connectionclause immediately returned{:error, :connection_failed}when the client was neither:connectednor:connecting. The caller is now parked while the configured retry/backoff ladder runs and receives its terminal normalized reason: for example{:error, :nxdomain}when no retry applies, or{:error, :max_reconnection_attempts}when configured retries are exhausted. Match{:error, reason}instead of the single atom.retry_count: 0skips the backoff ladder but still waits for the first attempt to fail. - BREAKING —
ClientSupervisor.start_client/2startup failures. A client process dying during startup now yields{:error, reason}instead of exiting the caller. Replacecatch :exithandling with an{:error, reason}match. - BREAKING — Deribit adapter JSON-RPC errors.
DeribitAdapter.authenticate/1,subscribe/2, andunsubscribe/2, plusDeribitGenServerAdapter.authenticate/1andsubscribe/2, now return{:error, reason}for JSON-RPC error bodies instead of passing an{:ok, error_body}through (or accepting a failed heartbeat acknowledgement). Match the error tuple before treating authentication, subscribe, or unsubscribe as successful. - BREAKING — subscription tracking follows operations, not market-data
ticks.
SubscriptionManager.handle_message/2tracks Deribit-stylepublic/subscribeandpublic/unsubscriberequests, applies correlated result frames, and clears failed operations. It no longer adds every"params.channel"seen in inbound data. Tracking the Deribit dialect only is intentional:build_restore_message/1always emits Deribit'spublic/subscribepayload, so callingSubscriptionManager.add/2andremove/2from a non-Deribit integration records channels but does not give that venue working reconnect restoration. Non-Deribit consumers should own their own re-subscribe on reconnect. - BREAKING — generic heartbeat state reports the active type. A generic
heartbeat replaces
active_heartbeatsinstead of accumulating every type ever observed. Treatget_heartbeat_health/1's list as current state; retain history in application state or from heartbeat telemetry if needed. JsonRpc.build_request/2and the JSON-RPC client example now type positional parameter lists as well as named maps. Runtime acceptance was already broad; the corrected spec lets onchain remove its upstream Dialyzer suppression for list parameters.ZenWebsocket.Clientremains the public GenServer. Call wrapping, Gun lifecycle, retry policy, frame routing, correlation, recording, and callback bodies were moved into responsibility-scoped submodules underlib/zen_websocket/client/. Apart from thereconnect_opts_from_state/1move documented above, supported Client signatures and return shapes are unchanged; the newClient.build_client_struct/2assembly helper is@doc falseand used byClientSupervisor.
Fixed
mix zen_websocket.validate_usagerecognizes nestedZenWebsocket.Client.*module references instead of reporting the module segment as an unknown Client function.- Client-owned Gun attempts use one retry state machine with Gun's independent retry disabled and attempt-specific timers, preventing duplicate reconnects and stale timeouts from terminating a later attempt.
- Supervisor discovery ignores non-PID child states such as
:restarting. :ping_pongheartbeats use a unique payload and count only the matching pong; a missed pong incrementsheartbeat_failures. Gun forwards pong frames to the client, andMessageHandlerno longer sends a duplicate pong for an inbound ping.- Pool health state survives the first caller exiting. Scores use pending requests, latency, and errors; the unreachable pressure penalty was removed.
- Failed Deribit authentication or subscription restoration no longer reports success or discards the subscriptions still needing restoration.
- Recorder buffers flush when their linked owner terminates abnormally.
0.6.1 - 2026-08-17
Changed — dependency refresh
- Resolved the published
descripex 0.12.1patch within the unchanged~> 0.12.0runtime requirement. - Updated development and analysis tooling:
ex_ast0.12.10 → 0.13.1,sobelow0.14.1 → 0.15.0,tidewave0.8.1 → 0.8.4, and transitivespitfire0.3.13 → 0.4.0. The direct dev/testex_astdependency explicitly overrides Reach 2.8.2's older~> 0.12.0declaration.
0.6.0 - 2026-08-01
Changed — {:descripex, "~> 0.11"} → {:descripex, "~> 0.12.0"} (breaking for consumers pinned below 0.12)
descripex 0.12.0 changed short_name in describe/1 output from an atom to a
string — a consumer-visible contract change shipped at a minor bump. The old
~> 0.11 bound (>= 0.11.0 and < 1.0.0) would have absorbed that silently on
any fresh resolution, so the requirement is now three-segment (>= 0.12.0 and < 0.13.0). A 0.x package that breaks on minor earns the tighter form; raise the
cap deliberately after reading its release notes.
zen_websocket itself does not read short_name — the 17 use Descripex sites
and the Descripex.Discoverable root module are unaffected, and no library or
test code changed. The break is in the bound, not the behaviour.
Minor, not patch, for the same reason 0.5.0's gun floor was: narrowing a runtime dependency requirement can fail resolution for a consumer pinned to descripex 0.11.x. Loud failure, but still a compatibility break.
Changed — lockfile
req 0.7.1 → 0.7.2 (dev/test only, via the doc/tooling stack).
0.5.0 - 2026-08-01
Changed — {:gun, "~> 2.2"} → {:gun, "~> 2.4"} (breaking for consumers pinned below 2.4)
gun 2.4.0 (2026-06-08) carries the fix for GHSA-w4f7-4cxr-rv3c. Under the old
~> 2.2 bound a fresh install could still resolve a vulnerable gun; raising the
floor makes the fixed version required rather than merely permitted.
This is a minor bump, not a patch, because narrowing a runtime dependency requirement can fail resolution for a consumer pinned to gun 2.2.x or 2.3.x. That failure is loud rather than silent, but it is still a compatibility break and semver should say so. (The 0.4.3 notes below state that declared bounds were unchanged — true for 0.4.3; this release is where they change.)
Changed — the coverage gate now measures something
--cover-threshold was 80 while actual coverage was 54.58%, so
mix precommit could never pass and CI ran a separate 50% workaround. A floor
above actual coverage enforces nothing.
Measured before deciding, and the first hypothesis was wrong: the gap is not
an artifact of --exclude integration. Integration-inclusive coverage
(excluding only external_network/stability*) is 67.67% — still short of
- The floor was unreachable under every run mode.
Coverage was raised first where it was cheap and meaningful — 54.58% →
58.29% — then the floor set to the measured value rounded down (58) at
both cover-threshold sites. Ratchet it as real coverage grows; never pad it.
New unit tests, all failure-capable rather than line-execution padding: Config
redaction edge cases, Debug logging on/off, ConnectionRegistry double-init,
MessageHandler routing branches, PoolRouter health formula and stale-error
clearing, Recorder malformed-data / corrupt-line / realtime-delay handling,
RecorderServer file-open failure and flush-timer paths, HeartbeatManager
telemetry gating, and ClientSupervisor.send_balanced/2 failover.
Honest split of the remaining gap: most is genuinely integration-only (live
Gun/WebSocket paths — 133 integration and 25 external_network tests of 570),
plus an ignore_modules gap in ex_unit_json --cover for Examples.* /
Mix.Tasks.*. Only a couple of one-line TOCTOU/IO-error branches are "hard but
not integration."
Changed — the quality gates now actually gate
reach.check --smellswas reporting findings and exiting 0. It raises only whenopts[:strict] || config.smells.strict, and neither was set..reach.exsnow setssmells: [strict: true]; findings get fixed, never ignore-listed.mix_auditadded and wired.deps.audit.gatedproves the advisory database is current before auditing —mix_auditdiscards its own sync exit status (mirego/mix_audit#61), so a database that can no longer sync still prints "No vulnerabilities found" and exits 0.agents.checkfails whenAGENTS.mdhas drifted fromCLAUDE.md.mix ciis the canonical repository gate. The GitHub Actions workflows were removed, so no check runs automatically on push.- MCP config mirrored to all four agent families (
.cursor/,.codex/,.grok/) — a server declared only in.mcp.jsonis invisible to cross-family agents.
.mix_audit_ignore carries exactly one entry, GHSA-w4f7-4cxr-rv3c, a verified
false positive for gun: the advisory covers cowboy (< 2.16.0) and gun
(< 2.4.0), but the mirror's importer groups by ghsaId alone, so both collapse
into one gun file carrying cowboy's range — and no cowboy file is written at all
(mirego/elixir-security-advisories#8).
Releases 0.4.3 and earlier are in CHANGELOG-archive.md.