@/.claude/includes/critical-rules.md
@/.claude/includes/harness-workflow.md
ZenWebsocket is a robust WebSocket client library for Elixir, specifically designed for financial APIs (particularly Deribit cryptocurrency trading). Built on Gun transport with reconnection, heartbeat, rate limiting, and request/response correlation.
Financial Development Principle: Start simple, add complexity only when necessary based on real data.
# Code Quality (use JSON output for AI-friendly results)
mix test.json # Run tests (see logs/warnings)
mix test.json --quiet # Run tests (clean JSON only)
mix test.json --quiet --failed --first-failure # Iterate on failures
mix dialyzer.json --quiet # Type checking
mix credo --strict --format json # Static analysis
mix security # Sobelow security scan
# Testing (integration tests excluded by default)
mix test.json --quiet --summary-only # Quick health check
mix test --include integration # Include integration tests (MockWebSockServer / Gun)
mix test --include external_network # Tests requiring internet (Deribit testnet, etc.)
mix zen_websocket.usage # Export usage rules
mix zen_websocket.validate_usage # Check Client API usage against the public surfaceSelf-contained so it survives into AGENTS.md on regen — cross-family reviewers (codex / cursor / grok) read AGENTS.md, not the Claude skill set.
- Canonical gate:
mix precommit.full(aliasmix ci) — the comprehensive pass the harness reviewer'scheck_commandruns, and the only thing that grades this repo: the GitHub Actions workflows were removed family-wide on 2026-08-22, so nothing runs on push. Run it before pushing. Fast local loop:mix precommit(skips the cold-PLT dialyzer + deps audit). mix precommit.fullruns, in order:compile --warnings-as-errors,format --check-formatted,credo --strict(ignoring TODO/FIXME tags),doctor --raise,ex_dna --max-clones 0(zero-clone budget),reach.check --arch --smells(policy in.reach.exs),sobelow --skip,deps.audit.gated,test.json --cover --cover-threshold 90 --exclude integration --include local_network(MIX_ENV=test),dialyzer(forcedMIX_ENV=dev— see below),agents.check.mix ex_dna --max-clones 0is not a byte-identical-function detector. The gate (and the same defaults onExDNA.Credoduringmix credo) is Type I + Type II withliteral_mode: :keep,min_mass: 30, Type III off (min_similarity: 1.0). Comments add no mass. Cross-module comparison works; what it misses are fragments below 30 AST nodes (the shared callback wrapper was mass 22–26; the shared heartbeatifwas mass 22) and above-threshold functions whose ASTs still differ after Type-II keep —__MODULE__vs a qualified alias, local vs remote call, reversed argument order (build_client_struct/2was mass 35–36 and still silent). Type II--literal-mode abstractalso missed those three; Type III at 0.85 flagged unrelated descripexapi()wrappers, not them. A green zero-clone run means nothing crossed that boundary, not that duplication is absent. See the comment on"ex_dna --max-clones 0"inmix.exs.- The coverage floor is a measured ratchet, not an aspiration. 90 is core-library coverage measured 2026-08-21 (
mix test.json --cover --exclude integration --include local_networkafter honoringtest_coverage: ignore_modules), rounded down. The previous 58 measured the diluted suite because ex_unit_json 0.6.0 ignores regexes in that list. Raise it in lockstep with real core coverage; never pad it. mix test.json(ex_unit_json) andmix dialyzer.json(dialyzer_json) emit JSON by design — this is NOT a build failure. Parse the JSON for real failures; never flag the envelope itself. Plainmix dialyzeris the authoritative dialyzer check when the JSON encoder can't serialize a warning shape.- The gate's dialyzer step forces
MIX_ENV=dev, not:test. Under:test, the test-only mock-server stack (cowboy,plug_cowboy,websock,x509,temp,stream_data) joins this repo'splt_add_deps: :apps_directanalyzed set and produces falseunknown_functionwarnings against the OOM-tuned PLT (seedefp dialyzerinmix.exs).preferred_envsindef cliis ignored inside alias steps, so the dev override is an explicitcmd env MIX_ENV=dev mix dialyzer. reach.check --arch --smellsgates from.reach.exs(smells: [strict: true]). Smell findings must be fixed for real, never added to an ignore list.deps.audit.gatedproves the local mix_audit advisory mirror is fresh (bin/advisory-freshness.shinonchain-stack) before runningmix deps.audit --ignore-file .mix_audit_ignore—mix_auditdiscards its own sync exit status (mirego/mix_audit#61), so a frozen mirror would otherwise report a false "No vulnerabilities found.".mix_audit_ignorecarries exactly one verified false positive (GHSA-w4f7-4cxr-rv3c ongun); do not add other advisory ids there — a real finding gets reported, never suppressed.
Use the existing docs instead of re-explaining patterns from scratch:
README.mdfor package overview and top-level discoveryAGENTS.mdfor contributor workflow and verification expectationsdocs/guides/building_adapters.mdfor adapter patternsdocs/guides/performance_tuning.mdfor telemetry and tuningdocs/guides/troubleshooting_reconnection.mdfor reconnect diagnosticsdocs/guides/deployment_considerations.mdfor production deployment trade-offs
lib/zen_websocket/
├── client.ex # Main client interface (GenServer + public API)
├── client/ # Nested as ZenWebsocket.Client.*
│ ├── call_facade.ex # Client.CallFacade — process-down-safe GenServer.call + connect await
│ ├── callbacks.ex # Client.Callbacks — handle_call/handle_info clause routing
│ ├── correlation.ex # Client.Correlation — JSON-RPC response/timeout correlation
│ ├── connection.ex # Client.Connection — Gun open, upgrade, attempt-identity timers
│ ├── frames.ex # Client.Frames — WebSocket frame routing and dispatch
│ ├── reconnect.ex # Client.Reconnect — explicit reconnect target and options
│ ├── recorder.ex # Client.Recorder — session recorder lifecycle
│ ├── retry.ex # Client.Retry — disconnect retry, backoff, stop-with-error
│ ├── retry_policy.ex # Client.RetryPolicy — retry eligibility and error normalization
│ └── transport_errors.ex # Client.TransportErrors — Gun error/down logging and retry dispatch
├── client_supervisor.ex # DynamicSupervisor for pooled connections
├── config.ex # Configuration struct and validation
├── frame.ex # WebSocket frame encoding/decoding
├── connection_registry.ex # ETS-based connection tracking
├── reconnection.ex # Exponential backoff retry logic
├── message_handler.ex # Message parsing and routing
├── error_handler.ex # Error categorization and recovery
├── json_rpc.ex # JSON-RPC 2.0 protocol support
├── request_correlator.ex # Request/response correlation
├── rate_limiter.ex # API rate limit management
├── heartbeat_manager.ex # Heartbeat lifecycle
├── heartbeat_interval.ex # Shared interval-pong telemetry + state update
├── subscription_manager.ex # Subscription tracking and restoration
├── latency_stats.ex # Latency percentile tracking
├── pool_router.ex # Health-based pool routing
├── recorder.ex # Session recording (pure functions)
├── recorder_server.ex # Async file I/O for recording
├── debug.ex # Conditional debug logging
├── safe_callback.ex # Crash-safe lifecycle callback wrapper
├── testing.ex # Consumer-facing test utilities
├── testing/
│ └── server.ex # Mock WebSocket server used by Testing
├── helpers/
│ └── deribit.ex # Deribit helper functions
└── examples/
└── deribit_adapter.ex # Deribit platform integration (plus other in-tree examples)
# Connection lifecycle
ZenWebsocket.Client.connect(url, opts)
ZenWebsocket.Client.send_message(client, message)
ZenWebsocket.Client.subscribe(client, channels)
ZenWebsocket.Client.get_state(client)
ZenWebsocket.Client.close(client)
ZenWebsocket.Client.reconnect(client)
# Monitoring
ZenWebsocket.Client.get_heartbeat_health(client)
ZenWebsocket.Client.get_state_metrics(client)
ZenWebsocket.Client.get_latency_stats(client)
# Public but @doc false — used internally by ClientSupervisor.start_client/2
ZenWebsocket.Client.build_client_struct(state, pid)- Maximum 5 functions per module (new modules)
- Maximum 15 lines per function
- Direct Gun API usage - no wrapper layers
- Real API testing only - zero mocks
All examples are written and tested in-tree under lib/zen_websocket/examples/ with matching tests in test/. Validate with compile, Dialyzer, Credo, and tests before considering an example done. Keep examples in this tree — a separate mix project under examples/<name>/ was tried (R026) and reverted.
- Executable examples: Live in
lib/zen_websocket/examples/without a per-file line limit - Packaging: Examples and
Mix.Tasks.ZenWebsocket.*ship in the Hex package; removing them would make existing example modules and tasks unavailable to consumers
export DERIBIT_CLIENT_ID="your_client_id"
export DERIBIT_CLIENT_SECRET="your_client_secret"url- WebSocket endpoint URLheaders- Connection headerstimeout- Connection timeout (default: 5000ms)retry_count- Maximum retry attempts (default: 3)retry_delay- Initial retry delay (default: 1000ms)heartbeat_interval- Ping interval (default: 30000ms)
When modifying any module, ensure it has both:
- Unit tests - Pure function logic, no network/I/O, fast execution
- Integration tests - Real connections via MockWebSockServer or external APIs
If either is missing, create them before completing the task.
:integration- Tests using MockWebSockServer, Gun, or external APIs. Excluded from defaultmix test; excluded from coverage unless paired with:local_network.:external_network- Tests requiring internet access. Excluded from defaultmix testand the coverage gate.:local_network- Mock-server socket tests retained in the coverage ratchet. Always paired with:integration, so defaultmix teststill excludes them.- Default
mix testexcludes every socket-opening test.
NO MOCKS ALLOWED - Only real API testing:
test.deribit.comfor Deribit integration- Local mock servers using
MockWebSockServer - Real network conditions and error scenarios
Rationale: Financial software requires testing against real conditions. Mocks hide edge cases that cause financial losses.
Two fenced carve-outs. Everything else remains prohibited.
Test doubles are permitted for Gun transport message tuples only — the four shapes :gun_upgrade, :gun_ws, :gun_down, :gun_error.
What is permitted:
- Constructing the four Gun tuple shapes for unit-level tests of pure functions that consume them (e.g.,
MessageHandler.handle_message/2) - Fixtures must use real
pid()values (fromself()orspawn) and realreference()values (frommake_ref/0). No fake opaque values.
Why this is not a real mock: Gun's pid and stream_ref are opaque BEAM primitives with no public contract. There is no behavior for a fixture to drift against — only a tuple shape. Shape-only fixtures enable property-based testing of routing totality without stubbing any behavior.
A test-only GenServer that answers only the three Client calls send_balanced/2 uses (:send_message, :get_state_metrics, :get_latency_stats) is permitted in client_supervisor_send_balanced_test.exs. send_balanced/2 reaches candidates solely through GenServer.call/2 on server_pid; the stand-in has no Gun connection, no frame handling, and no exchange semantics. It exists to drive failover and load-balancing deterministically (injected :ok / {:ok, map()} / {:error, reason} replies) without a live socket.
What is permitted:
- A
start_supervised/1GenServer that replies to those three calls - Injecting the exact reply
Client.send_message/2would return
What is NOT allowed (either exception):
- API response fixtures (Deribit, Binance, any exchange)
- Authentication flow simulation
- Exchange behavior simulation (subscription acks, order responses, heartbeats)
- Stubbing Gun, cowboy, or WebSocket frames
- Using the routing stand-in to test
ClientGenServer state, reconnection, or message handling - Any fixture with semantic content beyond the raw transport-frame shape or the three
send_balanced/2call replies
Source of truth unchanged: MockWebSockServer (real cowboy/websock stack) and real-API tests remain the source of truth for all business logic. client_supervisor_test.exs covers send_balanced/2 end-to-end against a real connection. Any test touching Client GenServer state, reconnection, subscription semantics, or exchange behavior continues to require MockWebSockServer or a real endpoint.
MockWebSockServer- Controlled WebSocket server (test/support/mock_websock_server.ex)CertificateHelper- TLS certificate generation (test/support/certificate_helper.ex)GunStub- Shape-only constructors for Gun transport tuples (test/support/gun_stub.ex)
- WebSocket connections are Gun processes managed by
ZenWebsocket.Client - Connection processes monitored via
Process.monitor/1 - Failures classified by exit reasons
{:ok, client} = ZenWebsocket.Client.connect(url, [
timeout: 5000,
retry_count: 3,
retry_delay: 1000,
heartbeat_interval: 30000
])Located in lib/zen_websocket/examples/deribit_adapter.ex:
- Authentication flow
- Subscription management
- Heartbeat/test_request handling
- JSON-RPC 2.0 formatting
- Cancel-on-disconnect protection
Supervised Pattern (production):
connect_opts = [
reconnect_on_error: false, # Adapter handles reconnection
heartbeat_config: %{...}
]Standalone Pattern (simple use):
{:ok, client} = Client.connect(url) # reconnect_on_error: true (default)gun ~> 2.4- HTTP/2 and WebSocket client (bound requires the GHSA-w4f7-4cxr-rv3c fix, not just permits it)jason ~> 1.4- JSON encoding/decodingtelemetry ~> 1.3- Metrics and monitoring
credo,dialyxir,sobelow,ex_doc,ex_dna(code duplication detection)
cowboy ~> 2.10,websock ~> 0.5,stream_data ~> 1.0,x509 ~> 0.8
Tasks live in roadmap/tasks.toml and are rendered to ROADMAP.md by rmap. Use rmap to list, create, score, and prioritize work.
Current ids are numeric (7, 8, …). Historical ids use R0NN (R026, R052). There is no WNX#### scheme.
rmap is the substrate. Status, scores, and write-sets live in roadmap/tasks.toml; ROADMAP.md is a generated view.
Priority uses D/B/U scoring (Difficulty / Benefit / Urgency). rmap next selects work from those scores.
- All connection tasks must include real API testing
- Platform integration tasks reference Deribit adapter patterns
- Frame handling tasks include malformed data testing
- Reconnection tasks test real network interruptions