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.

Status: EXPERIMENTAL

Stated here rather than only per-release, because a reader arriving at a specific version needs it as much as one reading the top.

This package has not run in production. While it is 0.x the API may change without a major version — pin three-part (~> 0.3.0). Coverage is uneven by design: fakes and live public endpoints are well covered, order placement and authenticated flows are not.

Whenever an endpoint moves to :proven, the entry that does it states the evidence — which venue, what was run against it, and when. "Marked proven" with no evidence is not an acceptable changelog line.

[Unreleased]

[0.3.120] - 2026-10-21

Added

  • Conformance group 54: every Order and Fill a venue's fake answers is one its own constructor builds. place_order, get_order, get_orders, get_trade_history and cancel_order answers are rebuilt through Order.new/1 / Fill.new/1. An unknown status, side, type or time_in_force, or a status beside a contradicting filled quantity, fails the group. It fails when place_order is active and nothing was examined. Public decisions order_value_fault/1 and order_values_examined/1, with teeth tests.
  • Capabilities.time_in_force/0, the closed time_in_force vocabulary.
  • Tests: Types.Order lifecycle (vocabularies, time_in_force, status against filled quantity, legs); a precedence test for all four fake-injection rules; link_event/2 and link_up/1 telemetry shapes; whole-table Timeframe pin; every Notice kind's default severity and every credential word; Config.get/3 and delete_override/1; :blocking buckets in HttpClient; a default-argument group in UnwiredCheck; Conversion.new/1 status and values; Capabilities.time_in_force/0; the Venue cancel_order/3 and get_historical_prices/4 doc sections; Reference venue cancel-twice and replace-after-cancel. Struct-literal tests that could not fail (Order, OrderBook, Quote defaults) now build through new/1.

Changed

  • Order.new/1 refuses what it cannot vouch for. time_in_force must be in the vocabulary or nil; :filled / :partially_filled with a stated filled quantity of 0, :pending / :open / :partially_filled with the whole quantity filled, and :filled with only part filled all raise. legs must be nil or a list of OrderLeg. Terminal statuses are not judged against filled quantity. BREAKING ONLY FOR A CALLER THAT PASSED ONE OF THESE TO new/1: venues build struct literals and are unaffected unless group 54 finds a fake that answers one, and all five venues' decoders now refuse such a venue row rather than build it.
  • Group 21 requires the exact {:error, {:unsupported_timeframe, width}} tag for an undeclared width. Any other answer is {:wrong_answer, answer}; an {:ok, _} is still a substitution.
  • Group 5 refuses a bare scalar success ({:ok, :atom} and the like) from a promised callback; {:ok, :cancelled} from cancel_order stays allowed.
  • Reference venue: cancel_order and replace_order refuse a terminal order ({:order_not_cancellable, status} / {:order_not_replaceable, status}), and get_trade_history returns a Fill.
  • Group 37: a range around the fake's own bars must be answered. range_fault/3 answers {:not_answered, answer} for a refusal, raise or non-list success of a range that must keep a bar; only the ask that must drop the bar may be refused. A fake refusing every range passed having examined no bound. The one-bar keep ask is now start: the bar and end: one width later (candle_range_probes/2), because a start: alone runs to "now" and a venue that bounds a window by its width refuses an old fixed bar for being too far back.
  • Groups 33 and 35 fail for a call declared active and not exported (unexported_calls/3), and no longer pass when nothing was eligible (required? is now true). A fake missing one of two calls was judged on the other.
  • SourceSafetyCheck (group 53): Module.concat over a call that makes text is flagged. String.*, to_string/1, inspect/1, Enum.join/2 and Atom.to_string/1 in an argument are text, as <> and an interpolation are. A module name or a pipe over Module.split/1 is not.

Documentation

  • Venue.get_historical_prices documents the unsupported-width tag; Venue.cancel_order documents that a terminal order stays terminal. usage-rules/testing.md, usage-rules/adapter.md and the building guide state 54 groups, the group 21 / 5 / 54 requirements and the Order.new refusals. The new/1 doc of every Types.* module that refused a value its doc did not name now names it (CorporateEvent, Filing, FinancialStatement, Funding, FxRate, NewsItem, OptionChain, OptionContract, OptionGreeks, Portfolio, ScreenerResult, Staking*, Watchlist, Withdrawal, ContractStats), OrderBook says a zero-size level is dropped by the decoder and refused by new/1, and a stale comment in LinkSafetyCheck is corrected.

[0.3.119] - 2026-10-21

Fixed

  • Conformance group 53 failed every venue on its tree-name derivation. Module.concat/1,2 was flagged for any argument that was not a literal, so each venue supervisor's Module.concat(name, "Feed") (the consumer's own tree-name atom plus a fixed suffix, the tick AO naming rule: one atom per tree started, never one per frame) read as an atom-table leak. Module.concat is now flagged only where an argument is visibly built from text: an interpolation, a <>, a term["key"] read or a Map.get/Map.fetch. The other atom-from-text calls are unchanged.

[0.3.118] - 2026-10-21

Tick AQ: numbers and identifiers from venue text (lens S), configuration, startup and option validation (lens T), unbounded state and resource cleanup (lens U), and the conformance groups that hold the three across the family. The new groups run in every venue's CI: a package that breaks one of the rules below now fails its own suite.

Added

  • Conformance group 52, order sizing and prices. Core.Venue.place_order/3 has always said "Quantities and prices accept a Decimal, an integer or a numeric string, and refuse a float (it has already lost digits), zero and negative values, and NaN and infinity", as {:invalid_quantity, value} and {:invalid_price, value}. Group 34 held items 1, 2, 5 and 8 of that check order and group 47 the amounts of the other writes, so nothing held this one: a Fake that sent a float quantity on, or placed an order of NaN, passed all fifty-one groups. The group hands the Fake's place_order/3 each of a float, zero, a negative, NaN and infinity as the :quantity and then the :price of the suite's own order request (one field bad at a time) and requires exactly {:error, {:invalid_quantity | :invalid_price, value}}, the value as given. A request that carries neither key fails the group rather than pass having probed nothing (declare one with order_args:). The cases are AdapterContract.order_value_cases/1.
  • Conformance group 53, source safety, and Core.SourceSafetyCheck. A static read of the package's own source (package_root:) and of its static config/*.exs: an atom built from text (String.to_atom/1, List.to_atom/1, :erlang.binary_to_atom/1,2, :erlang.list_to_atom/1, an interpolated atom :"x_#{y}", Module.concat/1,2 of anything but literals) is an atom-table leak (atoms are never collected); :erlang.binary_to_term/1,2 without [:safe] builds atoms and funs from bytes; Application.compile_env/3 and compile_env!/2 freeze a consumer's configuration at dependency-compile time; and System.get_env/0,1,2 and System.fetch_env/1, fetch_env!/1 outside config/runtime.exs read the environment where only that file may. The AST is read, so a mention in a comment, a docstring or a string is never a finding, and an argument that is a literal is not one. An empty scan is an error, as in groups 16, 18 and 19, and a file that cannot be read or parsed is an error naming it.
  • Types.Validate.integers!/2 (an integer or nil, for a sequence number) and Types.Validate.timeframe!/2 (a label in Timeframe.nameable/0).

Changed

  • Conformance group 26 ends its add/remove cycle by unsubscribing the last symbol and requires coverage/1 to be empty. The group checked only the symbols it named, so a Fake that kept an entry per symbol it was ever asked about (a tombstone, a "not covered" row) passed it and grew by one entry per distinct symbol for as long as it ran. The new fault is {:state_not_restored, symbols}.
  • Types.Candle.new/1 refuses an open, high, low or close at or below zero (a traded price is above zero; group 39 already held a Fake's candles to it) and a :timeframe outside Timeframe.nameable/0: Timeframe.aligned?/2 answers true for a width it has no rule for, so a bar labelled "1H" or "60m" passed the alignment check unexamined and was stored under a label nothing reads. Types.VolumeProfile.new/1 refuses an unnameable :timeframe the same way.
  • Types.Fill.new/1 refuses a :price of zero or less, as Types.Trade does (a negative :fee stays legal: a maker rebate). Types.Order.new/1 refuses a negative :quantity or :filled_quantity (zero and nil stay legal; prices are not held to a sign there). Types.Conversion.new/1 refuses a :rate of zero or less and a negative :from_amount or :to_amount.
  • Types.OrderBook.new/1 and Types.OrderBookDelta.new/1 refuse a :sequence that is neither an integer nor nil: "7" > 6 is true in Erlang term order, so a consumer dropping frames whose sequence is <= the last applied would apply a stale one. Instrument.new/1 refuses a :base or :quote that is present and not a non-blank string.
  • StakingRate.bps_to_pct/1 read an integer or a float straight into a Decimal: 10 ** 5000 came back as a Decimal with exponent 4_998, a value every Types.* constructor refuses, while the same number as text answered nil. An integer past the 34 digits a string may carry is nil now, and both go through the exponent bound the Decimal clause applies.
  • CanonicalPair refuses an :asset_aliases that is not a map (or list of pairs) of binary => binary, naming the mapping, in both directions. An atom, a nil or a number on either side raised FunctionClauseError from String.upcase/1, a non-collection raised Protocol.UndefinedError, both from inside the first symbol a venue converted; a blank code was accepted. Checked once per distinct alias map (the cache is untouched).
  • HttpClient.request/5 refuses a :provider or :rate_limit_key that is not an atom or a non-blank string (nil stays "none given"), with ArgumentError before anything is sent, like its neighbouring options. Both are printed into log lines and bucket names, and a map, tuple or pid raised Protocol.UndefinedError in the calling venue process on the first retry.
  • PollingFeed refuses a :label or :provider it cannot print or that Notice.new/3 would refuse, at start, as {:invalid_option, key, value}. A bad one raised inside the feed on the first failure or on the first delivering-nothing notice, and the restart lost every runtime update_symbols/2 scope. The documentation now says a key it does not list is ignored, not refused, because a venue forwards its own options to it unchanged.

Fixed

  • PollingFeed left its in-flight fetch running when it stopped normally. The fetch task is linked to the feed, and a link carries only an abnormal exit, so a GenServer.stop/1 or a supervisor's shutdown sent the task a :normal signal it ignored: a hung fetch (the case :fetch_timeout_ms exists for) outlived its owner, holding its socket. terminate/2 now shuts the task down and cancels its timer.
  • FakeInjection stranded a watcher process for every test whose agent died before its owner. The watcher monitored only the owner and killed the agent when the owner went down, so an agent that stopped on its own left the watcher alive until the whole test process ended. It now also monitors the agent and exits when it goes down.
  • ReferenceVenue compared an order or conversion amount before checking its exponent, so a caller's 1e999999999 made Decimal.gt?/2 allocate a number of that many digits in the venue's process before the refusal. An amount whose exponent is past Types.Validate's bound is now {:invalid_quantity | :invalid_price | :invalid_amount, value} unread, as NaN is.

Tests

  • A DateTime is compared with DateTime.compare/2 rather than == where a test asserts an instant (types_test, validate_test, timeframe_test, fail_closed_edges_test, venue_test, http_client_test).
  • Every assert_receive in telemetry_test carries a 5 s ceiling, and the two nil_recipient and lower-case delivery quiet windows guarding a negative are 500 ms, not 100.
  • The negative-only scans in credential_redaction_check_test and link_safety_check_test now compile a positive control beside the clean fixture, so a scan that found nothing because it read nothing fails instead of passing.
  • A default_rate_limiter_test case that asserted Enum.to_list(1..0//-1) (a fact about the standard library) is removed, and its one wall-clock bound is 30 s.

[0.3.117] - 2026-10-20

No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.

[0.3.116] - 2026-10-20

Fixed

  • PollingFeed in bulk mode (:fetch_all) ran its clock on a bare :poll_all message, so a duplicate or hand-sent tick enqueued a fetch and its reschedule/2 armed another tick: two clocks for good, the venue polled at twice the configured rate. The tick now carries a token kept in the feed's state ({:poll_all, token}), exactly as the per-symbol ticks already did, and only the armed one is acted on. A bare :poll_all is ignored.
  • DefaultRateLimiter.penalize/3 took any non-negative integer, so a hold past 2^32 - 1 ms (the longest acquire/3 can ever sleep, and its answer is :rate_limit_timeout for any longer wait even under timeout: :infinity) closed a bucket for as long as the limiter ran. It raises ArgumentError above that bound now. HttpClient's :max_retry_after_s is bounded to 4_294_967 seconds to match, refused in the caller before anything is sent: a larger ceiling used to raise from inside the 429 handling on the first venue Retry-After that reached it.
  • HttpClient opened its [:dp_exchange, :request, :start] span before it read the request's header names for the credential values to scrub, and outside the try that closes the span: a header whose name is not text (a tuple has no String.Chars) raised from there, leaving a :start with no :stop or :exception, against the documented "exactly one of them". The values are collected first, and a name that is not an atom or a binary is simply not a credential's name (it also raised from the plain-http credential check).
  • Types.StakingReward built with a :period_start or :period_end that was a Date, a string or an epoch integer, typed DateTime.t() | nil. Validate.new!/3 cannot hold them by name (a filing's same-named fields are Dates), so the new Validate.datetimes!/2 does it for this type.
  • Types.Conversion.expired?/2 read the instant of :expires_at itself as still open. A rate held until a time is not one to commit at that time, so it is expired from the instant onward, the safe direction for a call that guards a funds-moving commit.
  • CredentialRedactionCheck (conformance group 19) did not look for ten secret field names that Core.Notice refuses as detail keys (access_key, secret_access_key, api_secret_key, x_signature, x_auth_token, proxy_authorization, set_cookie, cb_access_key, cb_access_sign, cb_access_passphrase), though its moduledoc called its list the superset of Notice's: a struct holding an :access_key printed it in cleartext and passed. The list is 37 names now, and a test holds every Notice key to being on it.

Changed

  • Conformance group 39 (positive prices and sizes) counted any {:ok, _} as having examined a value, so a fake answering {:ok, []} for every price endpoint passed having looked at no price, size or level. A success counts only when it holds something to inspect (AdapterContract.value_examined?/1: a struct, a non-empty list, a book with a level).
  • Conformance group 37 (candles) now also refuses a bar labelled with another width than the one asked for ({:wrong_timeframe, label} from candle_series_fault/3, judged first): group 21 held the refusal of an unserved width, nothing held the label of a served one.
  • Conformance group 42 (ambiguous write): when the request carried a client_order_id, an {:order_unconfirmed, nil, _} is {:unconfirmed_id, nil}. The doc always said the id is the one sent; the check accepted nil. No venue's group-42 request carries one today.
  • Conformance groups 1 (required callbacks), 15 (coverage_by_kind/1 agrees with streamable) and 17 (credential gate) decided their verdict inside the generated test body, where the teeth tests could only assert canned answers. The decisions are public now: AdapterContract.missing_required_callbacks/1 and coverage_by_kind_fault/3 (which also names a coverage map that disagrees with the union of the by-kind sets, as {:union_mismatch, union, covered}), and the group-17 teeth tests assert what credential_gate_fault/1 answers. No change to what a conforming venue passes.

Documentation

  • usage-rules.md: the "which rows the suite asserts" paragraph named group 43 as the five market-data reads (it holds every endpoint) and omitted groups 46, 47, 49 and 50; the HttpClient option paragraph omits :decode_body as a flag and the write-retry default (:retry_attempts is 1 for a write, 3 for a read). usage-rules/feeds.md: the timer bound on PollingFeed's durations and the one-clock, token-checked tick. usage-rules/testing.md: group 42's id. usage-rules/adapter.md: the 37 secret names. docs/guides/: 51 groups, not 50.
  • PollingFeed moduledoc: its callbacks run in the feed's own process with no timeout, so one that never returns stops the feed (the fetch, in its task, is the only thing bounded).
  • Two PollingFeed tests guarded a negative with a zero-wait refute_received on a message sent by a fetch task, which no reply of the feed orders; they wait a real 500 ms now.
  • PollingFeed moduledoc: no callback (:sink, :on_refusal, :on_notice) is invoked from a handle_call/3, so a slow one cannot time out status/1 or coverage/1; they run in the feed's own handlers, inline, because the feed's ordering guarantee depends on it.
  • usage-rules/adapter.md and usage-rules/money-movement.md were read against the code and corrected where they disagreed with it (see the test changes below for what now holds them).
  • Tests that could not fail now can: PurityTest read Mix.Project.config() for an :application key that is never there; the package test names every guide the README links; the zero-notice wait in ReferenceVenue's decisions test and two Fanout refutes were zero-wait guards on messages sent from other processes (500 ms now); several Types.* tests asserted a struct literal's own fields and build through new/1; the SymbolNormalizer optional-callback test asked module_info/1 for an attribute that is never there. The reference-venue canned answers in VenueTest are labelled as what they are.
  • Capabilities: the :burst typedoc said an absent burst could be told apart from "a declared burst of zero", and new/1 has always refused a zero. It says what is true.

[0.3.115] - 2026-10-20

Documentation

  • Design record for tick AO (hot paths, process identity, secret hygiene), implemented across the family with its retrospective, in docs/design/closed/.

[0.3.114] - 2026-10-20

Fixed

  • Options.check/2 raised FunctionClauseError on an improper list ([a: 1 | :tail]) instead of refusing it: 0.3.113's credential redaction walked the refused value with Enum.map/2. It is walked by hand now and the improper tail is kept as given.

[0.3.113] - 2026-10-20

Documentation

  • Notice moduledoc: a feed restarted by its supervisor that resumed what it had accepted says so with :coverage_change and details.reason: :feed_restarted (with details.symbols), the shape every venue uses from tick AO.

Added

  • Conformance group 51: credential redaction in a running process (opt-in, process_probe: {Module, :function}; skipped without it, like group 41's facts). Groups 19 and 22 hold the credentials struct and the stored child spec; neither reaches the feed or socket process that keeps the credential for its lifetime and whose state OTP prints in a crash report. The probe starts the venue's process with no network from a canary-laden credentials map; the group renders :sys.get_status/2 and fails if the canary is in it, or if the probe examined nothing. AdapterContract.process_credential_fault/1 is public and has teeth tests against a process holding the raw map, a format_status/1 redactor and a redacting struct. The reference venue runs the group against ReferenceVenue.ProcessProbe. A venue not yet supplying a probe is unchanged; to satisfy it, see usage-rules/testing.md.
  • DefaultRateLimiter.child_spec/1 and PollingFeed.child_spec/1 take the child id from :name (PollingFeed falls back to :label). use GenServer's spec was always id: <module>, so two limiters (two accounts) or two feeds under one supervisor were refused for a duplicate child id. Unnamed ones keep the module as their id.

Fixed

  • HttpClient error text no longer quotes a credential the request carried. A venue 4xx or 5xx body that echoed the key ("invalid api key sk-live-..."), a transport exception message built from a header value, or a query token reached the error tuple, the retry warning and [:dp_exchange, :request, :exception] telemetry. The values of credential-named headers (and the token after a scheme such as Bearer), a URL's userinfo, and the value of a credential-named query parameter are now replaced with [redacted] in those texts, before the body excerpt is cut so a value straddling the cut is not left half-printed. Values under six bytes are not hunted for. get/3 with raw_status: true scrubs its HTTP <status> message the same way.
  • Notice.new/3 with non-map details (the likeliest wrong shape is a keyword list holding api_key:) raised "must be a map, got <the value>", printing the secret its own guard exists to keep out of a public issue. The message now names the kind of value only.
  • Options.check/2 answered {:error, {:invalid_options, opts}} with the caller's whole opts, :credentials included. The tuple keeps its shape; a :credentials entry in it is now :redacted.
  • A Types.* constructor's refusal names the offending value in bounded form. Fifteen messages (Validate: the identity, time, vocabulary, count, Decimal, text and shape checks; OrderBook, OrderBookDelta, Candle, Quote, Position, StakingReward, Withdrawal) put the whole value in the message with inspect/1, so a megabyte venue body or a caller-supplied map became a megabyte exception message, copied into logs and crash reports. They now go through Validate.show/1 (five items, forty printable bytes, clipped to 200 characters, because Decimal's Inspect ignores :printable_limit and prints every digit). A value that prints shorter than that reads exactly as before. The conformance suite's own failure messages bound the fake answers they print the same way.
  • Telemetry.link_down/2 metadata reason is bounded (50 items, 1 KiB of printable text, a longer text cut with its full size marked). A raw exit reason can carry a whole GenServer call, and the metadata is copied to every attached handler and exporter. Identical to the old output for any reason inside the bound.

[0.3.112] - 2026-10-19

Documentation

  • Design record for tick AN (dependency floors, HTTP error paths, consumer docs), implemented across the family with its retrospective, in docs/design/closed/.

[0.3.111] - 2026-10-19

Fixed

Tick AN: dependency floors (lens I), HTTP paths (lens J) and consumer docs against code (lens K).

  • HttpClient.request/5 refuses a :get that carries a body. mix.exs admits Req ~> 0.7, and Req 0.7.0 to 0.7.2 "automatically change GET to POST when request body is set" (0.7.3 reverted it). A request(:get, url, headers, body) was a read at the lock and a write at the floor, retried up to three times because a GET defaults to three attempts. A non-empty body on a :get is now ArgumentError before anything is sent; an empty body is sent as no body.
  • HttpClient treats userinfo in the URL as a credential for the plain-http refusal. Req 0.7.0 and later turn http://user:pass@host/x into a Basic Authorization header, and the backstop that refuses credentials over cleartext http looked only at header names, so the password went out in the clear. A remote http:// URL with userinfo is now refused unsent ("refused: credentials are not sent over plain http"); https, loopback and plug: are unchanged.
  • Types.VolumeProfile.point_of_control/1 no longer compares a price key with an absurd exponent. The keys are the venue's own strings and are not bounded by Validate. Decimal 3.x refuses to parse "1e999999999", but Decimal 2.x (admitted by mix.exs) parses it, and at the 2.0.x floor Decimal.compare/2 (no large-exponent shortcut until 2.1.0) then builds a number of that many digits, stalling the consumer's process. A key outside Validate.max_decimal_exponent/0 is unreadable, like any non-number.
  • Types.StakingRate.bps_to_pct/1 answers the same for text of more than 34 digits at every Decimal version. Decimal 3.x refuses to parse it (nil) and Decimal 2.x returned a rate, so the answer depended on the consumer's lockfile. It is nil at both.
  • AdapterContract.book_ordering_fault/2 names a NaN price. It handed every price to Decimal.compare/2, which raises Decimal.Error on NaN, so a fake's NaN level raised out of the decision inside the consuming venue's own suite. It is now {:unreadable_level, level}.

Documentation

  • docs/guides/building-an-exchange-package.md told a new venue to depend on {:dp_exchange_core, "~> 0.1.0"} (which resolves a Core with none of what a venue calls) and to expect 21 conformance groups; it is ~> 0.3.0 and 50, with a step for script/check_dependency_floor.sh.
  • usage-rules.md: the consumer example bound a variable named quote (a Kernel special form) and is renamed; the HttpClient section states the :get-with-body refusal.
  • usage-rules/symbols.md: "both directions are total" now says what does raise (a broken mapping, two aliases for one canonical code, a non-string symbol) rather than promising that nothing does.
  • usage-rules/feeds.md: Environment.streaming?/1 is Webull's own module, not Core's.

[0.3.110] - 2026-10-19

Documentation

  • Design document for tick AM (order-write discipline, group 50, group 17 teeth, stream lifecycle and schema-only decoding across the family), implemented in all five venues, with its retrospective, in docs/design/closed/.

[0.3.109] - 2026-10-19

Fixed

  • PollingFeed refuses a duration Process.send_after/3 cannot arm. :interval_ms, :start_delay_ms and :fetch_timeout_ms above 4_294_967_295 passed the integer check and raised from a timer: at init/1 for the first two, and for :fetch_timeout_ms on the FIRST FETCH inside handle_info/2, where the restart lost every runtime update_symbols/2 scope and raised again. All three now stop the feed at start with {:invalid_option, key, value}.

  • HttpClient.request/5 refuses a :retry_delay whose longest retry wait no process can sleep. The wait is retry_delay * n plus a quarter, and Process.sleep/1 raises past 2^32 - 1 ms; a delay of 2_000_000_000 with the default three attempts sent the request and raised in the calling venue process on the first retry. It is an ArgumentError before anything is sent, like the other retry options. One attempt never sleeps and is unaffected.

  • DefaultRateLimiter.acquire/3 answers {:error, :rate_limit_timeout} for a wait over 2^32 - 1 ms, timeout: :infinity included. The sleep happens after the reservation is committed, so a weight large enough to ask for years raised in the caller with the tokens already spent. The reservation is now not committed.

  • Types.OrderBook.new/1 refuses a level with a price or a size at or below zero, and Types.OrderBookDelta.new/1 a price at or below zero or a negative quantity (zero stays: it is how a delta removes a level). OrderBook's moduledoc already said a negative size or a price <= 0 refuses the whole book and a zero size is dropped; only shape and order were checked.

  • Types.*.new/1 hold the value rules each type's own doc states (they were prose only; a struct literal still builds anything). Candle.new/1 refuses an incoherent bar (high below low, or open or close outside [low, high]). Quote.new/1 and Trade.new/1 refuse a price at or below zero; TopOfBook.new/1 a bid or ask at or below zero (a venue's 0 is nil); Position.new/1 a quantity at or below zero. Fill, Balance, Conversion, DepositAddress and ApprovedAddress refuse a blank or non-text identifying field (order id, currency, asset, network, address); DepositAddress.memo_required is true, false or nil. Withdrawal needs an amount above zero, a fee not negative, and a :tx_id when :broadcast. StakingTransaction.amount is above zero and its progress, with StakingBalance, StakingRate and StakingReward amounts, not negative; a reward's accrual_count is a non-negative integer or nil. OptionContract needs a strike and multiplier above zero, Date expiries and boolean-or-nil flags; OptionChain keys its expiries on Dates; OptionGreeks, ContractStats, VolumeProfile and FxRate refuse negative open interest, volatility, volumes and non-positive rates and marks. NewsItem and Watchlist symbols are nil or a list of strings; FinancialStatement.line_items, CorporateEvent.details and ScreenerResult.metrics are maps; ScreenerResult.rank is a positive integer or nil; AuctionImbalance.side is the venue's string. New helpers in Types.Validate: positive!/2, text!/2, maps!/2, dates!/2, symbol_lists!/2.

  • PollingFeed reports unrequested symbols in a bulk answer that also carries wanted ones. Only the all-unwanted answer was reported; with one wanted event among them the rest were dropped in silence. The wanted events are delivered, then {:unrequested_symbols, names} is recorded exactly as in the all-unwanted case (status/1's last_error, the debug log).

Changed

  • Core.Venue: replace_order/4 and preview_replace/4 hold a change map to place_order/3's rules field by field (a non-map or empty map is {:invalid_order_request, v}, an unread key {:unsupported_order_field, k}, each present field, nil included, judged by its place_order rule), and preview_order/3 checks its request exactly as place_order/3. {:error, {:invalid_argument, :requests}} is the tag for a place_orders/3 argument that is not a proper list. The reference venue enforces all three. Conformance group 12 now calls preview_order/3 and replace_order/4 with endpoint_args/2 (a valid request and change map) instead of %{}; it only judges whether the answer is :not_supported, so a conforming venue passes either way. Venue-side impact: none in the suite; a venue whose replace_order/4 or preview_order/3 accepts an empty or malformed map now disagrees with the documented rule though no group holds it yet.

Added

  • Conformance group 50, order-write discipline of preview and replace, for the calls capabilities/0 makes active. replace_order/4 given an empty change map, nil, a string or a list answers {:error, {:invalid_order_request, value}}, and preview_order/3 given a request place_order/3 refuses (a non-map, no :order_type, an unread key) answers the identical tuple. Decision: order_write_discipline_cases/2 with case_faults/2. The reference venue passes; teeth in contract_teeth_decisions_test.exs (a venue that accepts anything, one that raises). Listed in assertions/0 (now fifty), usage-rules/testing.md and the Core.Venue text.
  • Group 17 no longer lets a raise stand for a refusal. Its decision is now the public AdapterContract.credential_gate_fault/1: with credentials stripped, {:ok, _} and a raise, exit or throw are faults. It cannot demand {:error, _} or {:refused, _} of every callback, because the group walks callbacks that return a bare value (subscribe/2 is :ok, coverage/1 a map), so those stay unjudged.
  • AdapterContract.child_spec_credential_fault/1, the decision of conformance group 22 (a secret passed in :credentials must not appear in what child_spec/1 hands a supervisor), public so it can be shown to fail. Group 22 had no teeth test: it was an inline refute that no broken fixture had ever been run through. contract_teeth_decisions_test.exs now hands it a child_spec/1 that stores the raw map, one that nests it, a redacting wrapper, and one that keeps no credentials.

Changed (test support)

  • The reference venue refuses what the contract refuses where it used to raise or answer. get_historical_prices/4 answers {:error, {:invalid_range, range}} for a range that is not a keyword list of DateTimes with :start before :end (a tuple raised FunctionClauseError and a string reached DateTime.compare/2), checked after the symbol and before the options. get_volume_profile/3 answers {:error, {:invalid_symbol, v}}, then the options, then {:refused, :not_listed} and {:error, {:unsupported_timeframe, tf}}; it used to answer a profile stamped with any symbol and any width.

[0.3.108] - 2026-10-18

Documentation

  • Design doc "Sent-once calls and write-rule assertions" (tick AL) implemented across the family, retrospective appended, moved to docs/design/closed/.

[0.3.107] - 2026-10-18

Added

  • Conformance group 43 probes every endpoint, not five reads. Each callback of Core.Venue that is active and that the Fake defines is called with an option nothing reads, writes and feed calls included; the answer must be {:error, {:unknown_option, key}}. The five original reads are judged on the probe alone; any other endpoint is also called without it and judged only if that call succeeded (a refusal of the generated arguments never reached the options), and the group fails if nothing could be judged. The callbacks that cannot take the probe are listed with a reason in AdapterContract.unknown_option_exclusions/0. New public decisions: unknown_option_fault/2, unknown_option_judged?/1, unknown_option_evidence?/2, unknown_option_probed/1, unknown_option_strict/0.
  • Conformance groups 47, 48 and 49 hold the parts of venue rules W4 to W6 a Fake can show. 47: a float, zero, a negative, NaN or infinity as the amount of stake/3, unstake/3, convert/4, withdraw/5, transfer_internal/4 is {:error, {:invalid_amount, value}}. 48: stake/3 and unstake/3 answer a valid call with Types.StakingTransaction; a refusal is not a pass. 49: get_order/3 and cancel_order/3 answer an id nobody placed {:refused, :not_found}. Decisions: invalid_amount_fault/2, staking_return_fault/1, unknown_id_fault/1. Core.Venue now says which of W1 to W7 are asserted and why the rest are not.

Changed

  • The reference venue (test/support) now refuses an unknown option on every endpoint, validates amounts (W4, including {:invalid_transfer, :same_account}), generates unique order ids and answers an unknown order id {:refused, :not_found} (W6).

Fixed

  • HttpClient: an undecodable non-2xx body labelled JSON no longer costs the response its status. Req returned its decode exception in place of the response, so a 429 with {bad json lost its Retry-After and bucket hold and was retried as a transport failure, a 400 was retried, and a write's 503 read "Request failed". A non-2xx body is now held aside while it streams and decoded leniently afterwards (JSON by content type, unless decode_body: false); if it does not decode it is passed through as the bytes. The 429 is a rate-limit answer with its wait, a 4xx is permanent, a 5xx is what it is, and with raw_status: true an undecodable error body arrives as a binary. A 2xx with an undecodable body is unchanged.
  • HttpClient: an error body that is not valid UTF-8 (binary page, cut multi-byte sequence) is shown as inspect/2ed bytes in the "Client error"/"Server error" message instead of putting invalid text into the error tuple.

[0.3.106] - 2026-10-18

Documentation

  • Design doc closed: transport keys only where read (docs/design/closed/). Every checklist item now records what each venue found, and a retrospective is appended. It notes that group 46 shipped wrong in 0.3.104, written from four venues instead of five, and that the 2026-10-17 premise that every venue forwards every key had never been measured.

[0.3.105] - 2026-10-18

Fixed

  • Conformance group 46 no longer probes subscribe/2. 0.3.104 required timeout: to be refused there too, on the premise that subscribing sends no request. Webull's subscribe sends the venue's subscription request and reads the HTTP keys, so the group would have failed a venue for honouring them. It now probes subscribe_notices/1 only, which sends nothing on any venue.

[0.3.104] - 2026-10-18

Added

  • Conformance group 46: a call that sends no request takes no transport key. subscribe_notices/1 and subscribe/2 on the Fake, given timeout: 5_000, must answer {:error, {:unknown_option, :timeout}}. The decision is AdapterContract.no_request_transport_fault/1. Tick AK measured every venue accepting the HTTP keys on feed calls that read none of them.

Changed

  • transport_options/0 is the shared vocabulary, not a list every call accepts (DpExchange.Core.Options moduledoc, usage-rules/adapter.md, design doc docs/design/2026-10-18_design-transport-keys-only-where-read.md). A call that sends a request allows the shared keys it actually forwards, and refuses one it cannot honour; a call that sends none allows only its own keys and :credentials. The old premise, that every venue forwards every key, was false in three venues: environment: :sandbox sent Robinhood orders to production, Schwab ignored base_url: with a live token, and max_body_bytes: bounded nothing on two venues' REST calls.

[0.3.103] - 2026-10-18

Added

  • Conformance group 44: the fake exports every public function the facade does. AdapterContract.missing_fake_exports/2 is venue.__info__(:functions) -- fake.__info__(:functions), sorted; the group fails with the missing {name, arity} list. Each venue already held this in its own tests (facade -- fake == []), so a sixth venue could ship a Fake with a hole and nothing shared would say so. The Fake may export more. Skipped when no fake: is given (group 1 fails that configuration by name). All five venues' own tests assert the same expression, so none is expected to newly fail.

  • Notice.conformance_faults/1 and conformance group 45: the documented notice shapes are now checkable. Pure, never raises, and Notice.new/3 still accepts every shape (a feed must not crash while reporting). Faults: {:reason_not_matchable, v} (details.reason present and neither an atom nor a tuple led by an atom: venue text belongs in details.message), {:message_not_text, v}, {:symbols_not_a_list, v}, :refusal_without_symbols, :data_quality_without_reason. Group 45 judges every notice a Fake delivers during subscribe_notices/1, subscribe/2 and unsubscribe/2. Every Fake in the family emits no notice today, so it judges nothing and is a tripwire, not evidence the real feeds conform; the real call sites are for each venue's own tests.

Fixed

  • Docs stated the opposite of the contract for unknown options. usage-rules.md said "an option a package does not recognise is ignored, not an error"; since 0.3.101 a facade call answers {:error, {:unknown_option, key}} (or {:invalid_options, opts}). It now says so, and lists group 43 among the rows the suite asserts.
  • Callback counts and the group count had drifted. usage-rules/adapter.md and usage-rules/testing.md said 88 callbacks with 3 optional; it is 89 with 4 (list_instruments/1, quantization/1, coverage_by_kind/1, wanted/1). The suite is forty-five groups, not forty-two (AdapterContract moduledoc and usage-rules/testing.md).

[0.3.102] - 2026-10-17

No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.

[0.3.101] - 2026-10-17

Added

  • DpExchange.Core.Options: a facade call refuses an option it does not read. check/2 answers :ok, {:error, {:unknown_option, key}} (a key holding nil counts) or {:error, {:invalid_options, opts}} for a non-keyword list; transport_options/0 lists the keys every call may carry (credentials, environment, base URL, the Req test seams, the limiter, timeout and retry knobs). A misspelled limt: used to return a plausible answer to a different question. Check order: positional arguments, then options, then credentials.
  • Conformance group 43: unknown options are refused. The Fake's get_price/2, get_top_of_book/2, get_symbols/1, get_order_book/2 and get_trades/2, called with the venue's endpoint_opts plus dp_contract_unknown_option: true, must answer {:error, {:unknown_option, :dp_contract_unknown_option}}; {:error, :not_supported} stays legal for an unserved endpoint. A venue whose Fake ignores a stray key now fails the suite until it adopts Options.check/2. usage-rules/adapter.md no longer says unknown options are ignored.

[0.3.100] - 2026-10-17

Changed (tick AJ)

  • use DpExchange.Core.AdapterContract refuses an option it does not know (fkae:, sample_pair:) or a non-keyword list with ArgumentError, at compile time. A misspelled option used to be ignored and the group it switched on ran on its default or was skipped, which read as a pass. AdapterContract.use_options/0 lists the accepted keys.
  • DefaultRateLimiter.start_link/1 stops with {:error, {:unknown_option, key}} for any option other than :name and :limits (limit: was ignored and the limiter ran on its default 10 requests a second).
  • HttpClient validates :decode_body as a boolean like the other flags ("false" was truthy and decoded when the caller asked for raw bytes).
  • Types.Trade, Fill (quantity), Candle, Quote (volume) and TopOfBook (bid_size, ask_size) refuse a negative size, quantity or volume in new/1 via the new Types.Validate.non_negative!/2. Zero and nil stay legal.

[0.3.99] - 2026-10-17

  • Conformance: two assertions could pass without examining anything (tick AI). Group 12's catalog_access: :query_only accepted any {:error, _} from get_symbols/1; it now needs exactly {:error, {:query_required, venue}} as usage-rules.md states (AdapterContract.catalog_access_fault/2, public and unit-tested). Group 38's :depth probe treated every refusal as a pass, so a fake that refused depth: 1 was never checked to honour it; only {:error, {:invalid_option, :depth, _}} (a venue minimum) is tolerated now, anything else is {:answered, _} (book_depth_fault/2). A venue's fake that did either will now fail its suite, which is the point. Group 10's title claimed "only the facade is public", which nothing checks; it now says what is checked (required callbacks exported). usage-rules.md and usage-rules/adapter.md say which refusal tags the suite asserts and that groups 33 asserts two single-symbol reads, not every one.

  • HttpClient :rate_limit_key (additive). Selects only the limiter bucket (default: :provider). A venue that meters order writes apart from reads passed a second :provider, and Core then echoed it into {:exchange_error, provider, _} and into every [:dp_exchange, :request | :rate_limit, _] event, so the venue identity was wrong. Now :provider is always the identity; the limiter receives the key and, as :telemetry_provider, the identity, which DefaultRateLimiter uses for its own events. Documented in usage-rules/adapter.md.

  • A blocking wait on two buckets was counted as two rate-limit hits (tick AI). With rate_limit_per_endpoint: true and rate_limit_blocking: true the pre-check check/3 and then acquire/3 each emitted [:dp_exchange, :rate_limit, :hit] for the same wait. DefaultRateLimiter.check/3 takes telemetry: false; HttpClient asks quietly on the blocking path and reports a wait beyond the timeout once itself.

  • A throw out of the transport left a [:request, :start] span open (tick AI). Only raises and exits were caught, so the throw skipped :exception and unwound into the calling venue process. It now closes the span with kind: :throw and returns {:error, _} like an exit. Telemetry moduledoc now states the :exception metadata exactly (kind, status: nil, result: :exception), the reconnect measurements and the pairing rule. HttpClient docs now list :plug, :adapter, :req_adapter, :headers, timeout: :infinity and retry_attempts: 0.

[0.3.98] - 2026-10-16

  • HttpClient.request/5 no longer retries a write by default (tick AH). The default of 3 attempts applied to every method, so a bare :post, :put or :delete that hit a transport failure or 5xx after the venue had accepted it was silently sent again and could place or move twice. The default is now 3 for :get and 1 for every other method; a venue that has an idempotency key opts in with an explicit :retry_attempts. Tests added for all three write methods, default and opt-in. Test hygiene: every refute_receive guarding a negative now waits at least 500 ms (was 50 to 200 ms).
  • Conformance group 42: an ambiguous write is :order_unconfirmed (tick AH). The suite never asserted it, because a tier-1 fake cannot fail after sending. New optional use option ambiguous_place_order: fn request -> ... end, a 1-arity function that runs the venue's real place_order/3 with a transport fault after the send. Group 42 asserts the answer is {:error, {:order_unconfirmed, id, reason}} (id nil, the sent client order id, or a list of ids) and that a request refused before sending (no :order_type) is a plain or refused error, never unconfirmed. Absent, the group is skipped (the opt-in pattern of group 41), so no venue breaks until it adds the option; each venue is expected to supply it. AdapterContract.ambiguous_write_fault/3 is public and unit-tested; Core's own suite supplies a stub function. Documented in the moduledoc, usage-rules/testing.md and usage-rules/adapter.md.

[0.3.97] - 2026-10-16

  • PollingFeed.update_symbols/2 validates its elements (tick AG). Only the container was checked, so an improper list ([1 | 2]) raised inside the feed in MapSet.new/1 and the restart lost every runtime scope, and a list with a non-binary element became a scope the per-symbol fetch was then called with. Both now raise ArgumentError in the caller, as :symbols at start is already refused. Documented the subscriber lifecycle a venue author must implement (monitor once, prune on :DOWN, release on empty, filter at send time) in usage-rules/feeds.md and the reference venue's moduledoc, which keeps no subscriber set.

[0.3.96] - 2026-10-15

  • Reference venue honours MD4 (tick AF). The worked example every venue author copies accepted and ignored :limit on get_historical_prices/4, and :since/:limit on get_trades/2, which Core.Venue says are honoured or refused, never ignored. Both now answer {:error, {:unsupported_option, key}}. get_trades/2 also checks the symbol first, refuses a non-boolean :include_broken, and answers {:refused, :not_listed} for a symbol it does not list (it stamped trades with any symbol it was given).
  • Tick AF, option and field validation parity.
    • HttpClient.request/5 (and so get/3) now refuses, with ArgumentError before anything is sent, a :timeout that is not a non-negative integer or :infinity, a :weight that is not a positive integer, and a :log_requests, :rate_limit_per_endpoint, :rate_limit_blocking or :raw_status that is not a boolean. raw_status: "false" was truthy and returned a 4xx as a success. A forwarded nil is still read as absent.
    • PollingFeed refuses at start, as {:error, {:invalid_option, key, value}}, an :on_refusal that is not a 2-arity function, an :on_notice that is not 1-arity, and a :symbols that is not a list or MapSet of binaries (a non-collection raised inside init/1; the callbacks were swallowed by guarded/3 at first use).
    • PollingFeed keeps its per-symbol timer refs and cancels the pending timer of a symbol removed by update_symbols/2, instead of leaving it to fire and be dropped.
    • Types.* constructors (Validate.new!/3) refuse a time field that is not a DateTime or nil: observed_at, venue_time, timestamp, as_of, active_from and every *_at. A string, epoch integer or Date built before and crashed the consumer's DateTime arithmetic. A venue fake that builds a type with such a value will now raise.
    • Validate.new!/3 also refuses a non-nil :symbol that is not a non-blank string and a non-nil :provider that is neither an atom nor a string; Position.new/1 refuses an :instrument_type that is not an atom or nil. A fake building symbol: "" or provider: 42 will now raise.
    • CanonicalPair.to_canonical/2 and to_exchange/2 raise ArgumentError for a mapping with a missing or non-binary :sep (a missing one was a KeyError one way and silently "no separator" the other) or a :quotes that is not a list of binaries. An absent :quotes is read as [].
    • PollingFeed poll ticks carry a token ({:poll, symbol, token}); only the tracked token is acted on, so a duplicate or hand-sent tick can no longer untrack a pending timer or start a second tick chain. Internal message shape; the public API is unchanged.
    • Reference.get_auction_imbalance/2 (test support) refuses a bad symbol with {:invalid_symbol, v} before looking at :auction.
    • Docs: usage-rules.md, usage-rules/adapter.md, usage-rules/feeds.md and the PollingFeed.start_link/1 doc state the new refusals.

[0.3.95] - 2026-10-15

  • HttpClient :max_retry_after_s is validated before anything is sent (tick AE). It was read only on a 429 and fed to min/2, so "30" or :infinity was silently ignored and -5 became a negative wait handed to the limiter. A non-integer or negative value now raises ArgumentError in the caller.
  • Conformance suite (tick AE): three groups that could pass having examined nothing now fail. A venue's fake that was passing them vacuously will fail on update until its fixture is fixed.
    • Group 7 (purity) fails when no compiled module of the package is found under the build path, instead of reporting an empty set of foreign modules as clean.
    • Group 37 (candles) fails when the unranged get_historical_prices/4 ask is refused, raises or returns no bars, instead of skipping the group (candle_ask_fault/1).
    • Group 39 (positive values) fails when the venue has active price endpoints and the fake refused every one of them (nothing_examined?/2).
    • Group 41: a nil answer to a named unlisted_symbol: or padded_credentials: is now a fault, not "did not opt in".
  • Docs: AdapterContract moduledoc and usage-rules/testing.md list every use option (endpoint_symbols: and package_root: were undocumented); assertion-coverage.md now says 41 groups and records the "fifth axis"; HttpClient's :retry_delay documents the backoff and exactly what is and is not retried, and request_error/0 names its third shape, {:response_too_large, limit}.

[0.3.94] - 2026-10-14

No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.

[0.3.93] - 2026-10-14

  • Facade consistency (tick AD): one reason for each of ten situations, documented. DpExchange.Core.Venue and DpExchange.Core.Notice (and usage-rules.md) now name: {:refused, :not_listed} (a well-formed symbol the venue does not list), {:refused, :no_quote} (listed, nothing to quote), {:refused, {:account_not_found, id}}, {:error, {:invalid_credentials, venue}} (padded or control-character credential, before signing, never echoed), {:error, {:insecure_url, venue}}, {:error, {:invalid_range, range}}, {:error, {:range_unavailable, timeframe, earliest: dt | nil, requested: dt}} beside :range_too_wide and :history_truncated; a poll route's :link_down closed by one latched :link_up; the notice kind rules (:data_quality for gaps and undecodable books, :refusal with details.symbols, :degraded only for a slower path, :credentials_rejected for a poll as well as a stream); and details.reason as an atom or tagged tuple with free text in details.message. Breaking, for venue packages: each venue moves its returned reasons to these (a changed reason, tracked in that venue's own CHANGELOG); Core itself returns none of them.

    • New optional callback wanted/1 (@optional_callbacks, so no venue fails completeness): the symbols the consumer asked to stream, against coverage/1's observed delivery. The contract is now 89 callbacks, 85 required and 4 optional.
    • Conformance: group 40 (when the fake exports wanted/1: a list or MapSet holding every symbol subscribed and every symbol coverage/1 reports) and group 41, opt-in and skipped by default: unlisted_symbol: (get_price/2 is {:refused, :not_listed}) and padded_credentials: (get_balances/2 is {:error, {:invalid_credentials, runtime_id}}). No existing venue run changes.
    • ReferenceVenue (test support) answers {:refused, :not_listed} where it said {:refused, :symbol_not_listed}, exports wanted/1 and refuses a padded credential. contract_teeth_test.exs pinned the old tag and is updated.
    • CanonicalPair: the upcased asset_aliases map was rebuilt on every to_canonical/2 and to_exchange/2 call (per message in a venue). It is cached in one :persistent_term, keyed by the caller's own alias map (so a changed mapping can only miss) and capped at 64 distinct maps. The quote-suffix sort is unchanged and stays per call, as documented.
    • FakeInjection: a symbol's by_symbol entry is removed when its last queued outcome is popped (it kept symbol => [] for the life of the agent); queuing no outcomes adds none.
  • Hot-path performance (tick AD).

    • PollingFeed: every fetch task closed over the whole feed state (symbols, scheduled, last_ok, the job queue) because fetch_fun/2 was called inside the task's fn, so spawning copied O(symbols) words per fetch, O(n^2) per cycle in per-symbol mode. The fetch function is now built in the feed and the task closes over it alone.
    • HttpClient: the retry backoff carries up to 25% random jitter on top of retry_delay * attempts_made (the configured delay is still the floor), so callers that failed together no longer retry together against a recovering venue.
    • Types.Validate.new!/3: the NaN/exponent/NaiveDateTime scan and the float scan were two walks of every field; they are one. Tuples are walked by index without a list copy. Behaviour is unchanged (floats are still allowed inside free-form maps).
    • Types.OrderBook.new/1: the sort check is one walk over adjacent levels instead of three passes and a list of pairs per side.
    • Fanout.deliver/4: subscribers are resolved and filtered in one pass.

[0.3.92] - 2026-10-14

  • Types.*.new/1 now refuses a non-Decimal in a Decimal field (behaviour change for venue decoders). A decoder that passed "abc", "1.5" or a map through built fine and raised later inside Decimal.compare/2 in the CONSUMER's process (TopOfBook.mid/1, crossed?/1, VolumeProfile.point_of_control/1). It now gets an ArgumentError at construction naming module, field and the (truncated) value. A Decimal or an integer is accepted; nil only where the type's typespec allows it. New Validate.decimals!/2, decimal_map_values!/2 and decimal!/3. Declared per type from its own @type t: Candle, TopOfBook, Quote, Fill, Order, Trade, Balance, Position, FxRate, Funding, ContractStats, Conversion, CorporateEvent, AuctionImbalance, OptionContract, OptionGreeks, StakingBalance (incl. by_provider values), StakingRate, StakingReward, StakingTransaction, Withdrawal, VolumeProfile (incl. price-map values). Also checked inside collections: OrderBookDelta levels (price and quantity; OrderBook already did) and OptionChain strike keys and underlying_price. A venue that builds with strings must parse them with Decimal.parse/1 first.

  • PollingFeed: delivering an event struct whose module atom has no Elixir. prefix (an Erlang-style struct module) raised ArgumentError from Module.split/1 inside the feed on every delivery; the link-event type now falls back to the atom's own name.

  • Decoder robustness against hostile venue payloads (tick AC).

    • HttpClient.get/3: a 2xx body served as text/plain that is a bare JSON scalar (null, 42, "x", true) was returned as {:ok, nil} / {:ok, 42}; the same bytes as application/json were already {:error, "Unexpected response format"}. Both are now refused, including under decode_body: false.
    • HttpClient.parse_rate_limit_headers/1: a negative x-ratelimit-limit or x-ratelimit-remaining was returned as a budget; it is now nil ("did not say").
    • Types.Validate.new!/3: a Decimal with an exponent beyond +/-1000 ("1e5000" parses in full) is refused in any field. Decimal.add/2 and compare/2 align exponents with 10^difference, so one such price made a consumer's TopOfBook.mid/1, crossed?/1 or VolumeProfile.point_of_control/1 allocate a number of that many digits. New Validate.max_decimal_exponent/0. StakingRate.bps_to_pct/1 answers nil for one.
    • Timeframe.boundary/2 raised ArgumentError for a DateTime before year 0000 (it used from_unix!/1); it now buckets across the whole calendar range.
    • PollingFeed: a bulk fetch answering {:ok, improper_list} or {:refused, improper_list} raised inside the feed (losing every runtime update_symbols/2 scope); it is now recorded as {:bad_fetch_result, {tag, :improper_list}} and the feed continues.

[0.3.91] - 2026-10-13

  • PollingFeed (bulk): a result about symbols removed while the fetch was in flight was a failure. With the whole scope removed it raised the delivering-nothing warning, an outage notice and [:link, :down] for symbols nobody wanted; with part of it removed, an answer only about the removed symbols was recorded as {:unrequested_symbols, _}. Both are now dropped, judged against the set the fetch was asked for. Only a symbol it was NOT asked for is unrequested.
  • HttpClient (blocking, per-endpoint): a refused endpoint bucket spent the provider's token. The provider bucket was reserved and waited for, then the endpoint bucket answered :rate_limit_timeout, and nothing was sent. Both buckets are now asked first with check/3 (which reserves nothing), as the non-blocking path already did.
  • Docs: DefaultRateLimiter states that a reservation is spent when it is made, so a caller that dies before sending leaves its slot spent (bounded, and in the safe direction).

[0.3.90] - 2026-10-13

  • HttpClient: :req_adapter raised instead of working. It is the documented transport seam (and four venue packages forward it), but it was handed to Req under its own name and Req 0.7.5 raises unknown option :req_adapter, in the caller's process. It is now translated to Req's :adapter; an explicit :adapter wins.
  • HttpClient.get/3 nested its own error tag. With :provider, a failed request came back as {:exchange_error, p, {:exchange_error, p, msg}}; it is tagged once. A body that is not JSON, or not an object or list, is now tagged with the venue like every other failure.
  • The plain-http credential backstop ignored a map of headers. Req accepts headers as a map, and one carrying Authorization went out over http unrefused; a map is read like a list.
  • Telemetry.endpoint/1 kept the userinfo of a scheme-relative URL (//user:pass@host/x); it is stripped like any other authority's.
  • Internal: unreachable catch :exit clauses in DefaultRateLimiter.record/3 and penalize/3 removed (call/2 already catches every exit); both still answer :ok.
  • Tests: every line the suite never executed was read and either tested, proven dead and removed, or kept as a commented defensive clause.

[0.3.89] - 2026-10-12

  • Security: HttpClient no longer quotes a refused request into its error. Mint's {:invalid_header_value, name, value} carries the header's value (an Authorization or signature header, typically after a stray newline in a key), and it was printed into the error returned to the caller and into [:dp_exchange, :request, :exception] telemetry; so were the request target of {:invalid_request_target, _} and the term/value of an exception the pipeline raised. The header name and the kind of failure remain; the content is redacted.
  • Security: HttpClient refuses to send credentials over plain http. A request that is signed per attempt or names a credential-shaped header (authorization, key, sign, token, passphrase, secret) to an http:// URL on a non-loopback host answers {:error, "refused: credentials are not sent over plain http"} before anything is sent. A backstop for every venue whose base URL can be overridden; loopback hosts and plug: stay allowed.
  • Security: PollingFeed no longer carries what a failing fetch held into status/1, its logs or its notices. An exception other than RuntimeError travels as its module name (KeyError and friends quote their arguments, which can be a credential map); a thrown value or exit reason keeps an atom, a number or a tuple of those, keeps the leading atom of anything else and withholds the rest (an exit from GenServer.call/3 quotes the call's request).
  • Security: Notice.new/3 refuses more credential-shaped details keys (cb_access_key, cb_access_sign, cb_access_passphrase, x_signature, x_auth_token, proxy_authorization, set_cookie, access_key, secret_access_key, api_secret_key), and a default message quotes details.reason bounded.
  • Tests: an assertion-1 decision test borrowed a module another test file defines, and failed with :nofile whenever that file had not loaded yet; it has its own module now.

[0.3.88] - 2026-10-12

  • Docs: usage-rules.md states that venue_does_not_serve/0 is optional and not a Venue callback (check it with function_exported?/3), documents the unconfirmed-write shapes a caller must not blindly retry (order_unconfirmed, write_unconfirmed) and the locally decided invalid_* refusals, and lists every notice kind.

[0.3.87] - 2026-10-12

  • PollingFeed: a fetch task killed from outside took the feed down with it. Each fetch runs in a task linked to the feed, and the feed did not trap exits, so a task killed by a heap limit, a crashing process it had linked to, or Process.exit/2 killed the feed. The supervisor restarted it from its start options, and every scope set by update_symbols/2 was lost. The feed now traps exits: the fetch is recorded as failed and the feed and its scope survive. A parent's or supervisor's shutdown is still honoured.

[0.3.86] - 2026-10-12

  • Docs: docs build is warning-free (18 references fixed).

[0.3.85] - 2026-10-12

Changed

  • Conformance decisions that lived only inside test bodies are public functions, and each is shown to fail. Assertions 1, 3, 5, 8, 11, 14, 23, 24, 25, 26, 27, 28, 29 and 30 made their verdict inline in the quoted suite, so nothing could hand them a value that was wrong in one specific way. They now call AdapterContract.half_implemented_optionals/1, provider_name_fault/1, runtime_id_fault/2, asset_classes_fault/1, answer_type_fault/2, unrecorded_absences/2, streamed_but_absent/2, unknown_stream_kinds/1, carries_process?/1, time_fault/2, balance_fault/1, book_ordering_fault/2, subscription_set_fault/3, nil_recipient_fault/4, streaming_call_fault/1, lower_case_delivery_fault/4 and volume_window_fault/1, and contract_teeth_decisions_test.exs hands each one the specific wrongness it exists to catch. The checks are the same ones, with two tightenings: a Balance.currency of only whitespace is refused (assertion 24), and a Balance.provider of "" is refused as well as nil. The assertion 11 test whose name claimed the facade takes no functions (it asserts child_spec/1 is exported) is renamed for what it asserts.
  • The reference venue obeys the conventions its own suite checks. get_historical_prices/4 returned a bar at the quote's time whatever the width, so a "1d" request answered a bar opening at 12:00; the bar now opens on the width's boundary (assertion 37's grid). get_order_book/2 raised on a :depth that was not a number and clamped one above its two levels; it now answers {:error, {:invalid_option, :depth, value}} for anything but 1 or 2 (Core.Venue, MD5).

Fixed

  • Timing-dependent tests in PollingFeed, HttpClient, DefaultRateLimiter and Telemetry. A removed-and-re-added symbol's timer test compared fetch counts over Process.sleep(500) and now compares them over twelve fetches of the control symbol; the "once, not once per cycle" refusal test waits for four fetch cycles instead of a 300 ms quiet window; the queued-symbols test waits for the queue to be full instead of sleeping 300 ms; the dark-feed test polls status/1 instead of sleeping 250 ms; in-flight-result tests read the mailbox after the feed answered a call instead of after a 100 ms window; the boot-delay test uses a one-second delay against a 150 ms window; penalize/3 tests hold for a minute, not two seconds, so a loaded machine cannot move the remaining wait out of range; the weight-N and trickle-response tests no longer assume a refill or a response takes less than a second; the huge-negative-reset test counts reductions instead of milliseconds; and the telemetry tests wait 5 s for events. "The caller waits, not the limiter" could not fail (a limiter blocked for the wait still answered within the call timeout); it now holds a twenty-second reservation and requires another provider to be answered while the first caller is still asleep.

[0.3.84] - 2026-10-12

Fixed

  • Assertion 38 asked with the venue's own :depth. The probe appended depth: 1 and limit: 1 to the venue's endpoint_opts: for get_order_book/2; a keyword's first entry wins on lookup, so a venue declaring its own depth: there was asked for that depth, not one. The probe's options now replace the same-named keys.
  • Docs contradicted each other and the code. A snapshot book level of size 0 is dropped and a negative size refuses the book (the docs said size <= 0 was dropped in one place and a negative refused in another); usage-rules.md said unrecognised options are ignored without the family-option exception; adapter.md said the suite did not assert the market-data conventions (assertions 36 to 39 do, in part); testing.md and assertion-coverage.md gave stale group counts; TopOfBook still described Quote's removed :timestamp; PollingFeed named a :not_covered entry that coverage/1 never contains. usage-rules.md gained a consumer-facing summary of the market-data conventions.

Changed

  • Per-request and per-tick work no longer builds strings nobody reads: HttpClient's request debug line and PollingFeed's per-failure debug line are lazy (Logger.debug/1 with a function), HttpClient parses the URL path only when rate_limit_per_endpoint needs it, and Telemetry.endpoint/1 skips its userinfo regex when the URL has no @. No behaviour change.

[0.3.83] - 2026-10-12

Added

  • Conformance assertions 36 to 39 lock the tick R market-data conventions into AdapterContract, run against the venue's fake: 36 a non-boolean include_partial is {:invalid_option, :include_partial, v}; 37 candles are strictly oldest first and aligned, and a range keeps :start and drops :end (re-asked from the fake's own bars; a refused range is skipped); 38 get_order_book/2 honours depth: 1, and limit: 1 is accepted or refused {:unsupported_option, :limit} (an ignored :limit is tolerated, as Core's usage rules let a venue ignore options it has no use for); 39 every Quote, Trade and Candle price and every book level's price and size is above zero, books best first. A venue whose fake validates :depth but returns a fixed ladder now fails 38 (expected: Gemini, Webull). Decisions are public functions (probe_timeframe/1, candle_series_fault/3, range_fault/3, book_depth_fault/2, book_limit_fault/2, market_value_fault/1, ...).
  • MD3 is narrowed for session-traded instruments (documented on DpExchange.Core.Venue.get_historical_prices/4 and Candle): crypto stays strict on the UTC grid; for equities, options, futures and events widths of 30 minutes and under stay strict, wider intraday widths need only 30-minute alignment (sessions open 09:30 ET), daily and longer map to 00:00 UTC of the session date.
  • The reference venue's fake honours :start/:end, :depth and a boolean include_partial.

[0.3.82] - 2026-10-12

Changed (documentation of the family contract; no behaviour in Core changes)

  • Market-data conventions MD1 to MD11 are documented on DpExchange.Core.Venue.get_historical_prices/4, DpExchange.Core.Venue.get_price/2, DpExchange.Core.Venue.get_top_of_book/2, DpExchange.Core.Venue.get_order_book/2, DpExchange.Core.Venue.get_trades/2 and DpExchange.Core.Venue.subscribe/2, and in the Candle, Quote, TopOfBook, OrderBook and Trade moduledocs, plus usage-rules/adapter.md. They bind REST, streams and Fakes:
    • BREAKING for venues: the in-progress candle is returned only with include_partial: true (default false); Webull stock's real_time: becomes include_partial:.
    • BREAKING for venues: :start is inclusive and :end exclusive on opened_at; an absent :end is now; a range one response cannot hold is walked or {:error, {:range_too_wide, _}}.
    • opened_at is the bucket start; a bar not aligned to its width from a venue whose time is not provably the open is {:error, {:unaligned_candle, time}}. Equity daily and weekly bars map to 00:00 UTC of the same session date.
    • BREAKING for venues: :depth is levels per side on every venue; Coinbase's :limit on the order book is {:error, {:unsupported_option, :limit}}.
    • An option a venue cannot honour is {:error, {:unsupported_option, key}} or {:error, {:invalid_option, key, value}}, never ignored. A since: trade read that returns exactly limit rows is {:error, :history_truncated}.
    • Book levels: unreadable level, negative size, price <= 0 or a non-list side refuses the book; a snapshot level of size <= 0 is dropped. Traded prices <= 0 refuse; a bid or ask of 0 is nil.
    • Quote.venue_time is the venue's time of the last trade and TopOfBook.venue_time its time of that quote, nil when none is stated, never a Date header, envelope or send time. volume_window: :print only when every print is delivered; a conflated stream reports :running_total.
    • Delta streams resubscribe on a sequence gap and drop stale frames. Trade.side is the taker's; undocumented side codes (Webull B/S) are nil or recorded as unmeasured.
    • Numbers declared from vendor docs are labelled documented, not measured.
    • Not asserted by the conformance suite yet: the venues are still changing.
  • Order-request documentation updated on DpExchange.Core.Venue.place_order/3, DpExchange.Core.Venue.replace_order/4 and DpExchange.Core.Venue.place_orders/3: an unknown request key is refused whatever its value, nil included (DV1); sizing refusals are tagged tuples ({:missing_field, :quantity}, {:unexpected_field, :quote_size} or the sizing key) (DV4); a 2xx placement with no readable id is :order_unconfirmed (DV5); a 4xx answered to the first attempt is {:refused, _} except 429, and one answered to a retry is unconfirmed (DV6); a GTD expiry is checked with the time in force, before price fields (DV10). This replaces the 0.3.79 text that said a nil-valued key counts as absent.

Changed

  • DpExchange.Core.Venue.order_unconfirmed/0 widened: the id is String.t() | [String.t()] | nil. A list is the client order ids of a batch placement (Webull), in request order. Type only; no callback result changes shape for single orders.

[0.3.81] - 2026-10-12

Fixed

  • Conformance assertion 32 and the other convention checks no longer fail a venue that declares every call they would drive :unsupported ("no call was eligible" is a failure only when one of the calls is declared active).
  • Assertion 34's market-price check no longer false-fails a venue that refuses a good-till-date duration by naming its expiry key ({:missing_field, key}), or one that checks a stop price on the base request before the price: the request now drops :stop_price, and any {:missing_field, key} other than :order_type counts as a duration refusal.
  • AdapterContract.nothing_checked?/3 holds the "declared active but not exported" decision behind assertions 32 to 35, with teeth tests for both directions.
  • Venue.place_order/3 doc: a missing side is {:missing_field, :side}, a missing quantity or needed price is {:missing_field, key}, and a nil-valued unread key counts as absent.

[0.3.80] - 2026-10-12

Fixed

  • Assertion 34 failed every venue that declares no :market order type. With no market type market_price_requests/2 builds no requests, and market_price_fault([]) demanded a price refusal anyway. An empty answer list is now nil: there was no price to refuse.

[0.3.79] - 2026-10-12

Added

  • The family conventions are part of the contract, and four new conformance assertions hold a venue's Fake (and, for 32, its facade) to them. Every venue already follows them; until now only prose said so.

    • 32. Streaming arguments are validated first. On subscribe/2, unsubscribe/2 and update_symbols/2 a malformed symbol list (not a list, improper, a non-string or blank element) is {:error, {:invalid_symbols, value}}, never :ok. On subscribe/2 and subscribe_notices/1 a :to that is not a pid or a non-boolean atom is {:error, {:invalid_subscriber, value}}; symbols are judged before the subscriber. Run against the Fake and against the real facade with absent_feed_opts (the no-feed path of assertion 28: nothing starts, nothing reaches the network), so bad input answers the same whether or not a feed runs.
    • 33. A symbol that is not text is refused before credentials. nil, a number or a blank string on get_price/2 and get_top_of_book/2 is {:error, {:invalid_symbol, value}}, asked with credentials stripped.
    • 34. place_order/3 request discipline. A non-map is {:invalid_order_request, r}, a request with no :order_type is {:missing_field, :order_type}, an unread key is {:unsupported_order_field, key}, and a :price on a :market order is {:unexpected_field, :price} (tried with no duration and with each duration the venue's capabilities/0 names, because the duration a venue takes on a market order is its own). Skipped when place_order/3 is :unsupported; the market check is skipped when :market is not a supported order type.
    • 35. Order ids are checked first. nil or a blank id on get_order/3 and cancel_order/3 is {:error, {:invalid_order_id, value}}, with credentials stripped and with and without the venue's endpoint_opts:, so neither the account option nor credentials may be the answer.
    • A callback a venue declares :unsupported is skipped, as elsewhere. Each decision is a public function with a teeth test (refusal_fault/2, eligible_cases/3, case_faults/2, streaming_refusal_cases/2, single_symbol_refusal_cases/1, order_request_cases/1, market_price_requests/2, market_price_fault/1, order_id_refusal_cases/1), run against a module that breaks every convention at once.
    • Expect a venue's CI to go red if its Fake or facade drifts. A venue whose order_args: %{order_request: ...} is not a request its Fake accepts must declare one.
  • Core.Venue documents the conventions where a venue author reads them. The callback @docs of get_price/2, get_top_of_book/2, subscribe/2, unsubscribe/2, update_symbols/2, subscribe_notices/1, coverage/1, place_order/3, preview_order/3, replace_order/4, cancel_order/3, get_order/3 now state the error shapes, the check order (arguments, then feed; request, then account option, then credentials), that subscribe([]) opens nothing, that a cancel is sent once, and that a preview the venue rejects is {:refused, {:preview_rejected, rejects}}. New types: invalid_argument/0, order_request_error/0, order_unconfirmed/0, write_unconfirmed/0 and order_write_result/1 (the specs of place_order/3 and replace_order/4 now use it; the accepted answers are unchanged).

    • Unconfirmed order writes. An outcome of place_order/3 or replace_order/4 that may have reached the venue and is not proven is {:error, {:order_unconfirmed, client_order_id_or_nil, reason}}, never a bare error and never a refusal; a replace that was accepted but could not be read back is {:error, {:replace_accepted_unread, order_id, reason}}.
    • Non-order writes (documentation only this release; no assertion yet). withdraw/5, transfer_internal/4, convert/4, commit_conversion/2, stake/3, unstake/3, cancel_all_orders/2 and the other state-changing writes document rules W1 to W7: {:error, {:write_unconfirmed, key_or_nil, reason}} for an outcome that may have reached the venue, retries only under an idempotency key the venue honours, a confirming body with missing optional fields is {:ok, struct}, amounts as Decimal/integer/numeric string with {:invalid_amount, v} and {:invalid_transfer, :same_account}, Types.* returns in the real path and the Fake, and list reads that refuse an unreadable row or a possibly truncated reply ({:error, :history_truncated}).

Changed

  • The reference venue (Core's own Fake) follows the conventions. get_price/2, get_top_of_book/2, get_order_book/2 and get_historical_prices/4 answer {:error, {:invalid_symbol, v}} for a nil, non-string or blank symbol (was a {:refused, :symbol_not_listed}); subscribe/2, unsubscribe/2, update_symbols/2 and subscribe_notices/1 refuse a malformed list or :to; get_order/3, cancel_order/3 and replace_order/4 refuse a blank id; place_order/3 validates its request in the family order and returns the order asked for (a side atom, Decimal amounts), where it accepted anything and defaulted a missing type and side. subscribe_notices/1 no longer sends a :link_up on registration (convention 9), and refuses a bad :to. Test support only; not shipped.

[0.3.78] - 2026-10-11

Fixed

  • DpExchange.Core.Config.find_override/1 works on OTP before 26.2. 0.3.77 read one dictionary key with Process.info(pid, {:dictionary, key}), an item OTP added in 26.2; Elixir 1.18 still runs on OTP 25 and 26.0-26.1, where it raised ArgumentError. On such a runtime the whole dictionary is read, as before 0.3.77. CI runs OTP 28 only, so that path is unmeasured.

  • PollingFeed bounds a text failure reason in its :coverage_change notice. A binary reason travelled to every on_notice handler at whatever size the caller's fetch answered; it is now cut at 2,048 characters. Shorter reasons are unchanged.

[0.3.77] - 2026-10-11

Fixed

  • DpExchange.Core.Config.find_override/1 reads one key from each $callers ancestor, not its whole process dictionary. Process.info(pid, :dictionary) copied every entry on each lookup, and HttpClient looks up several keys per request, so a call from a Task under a process with a large dictionary paid for that copy every time. Same resolution order and result.

  • [:dp_exchange, :request, :exception] no longer carries the arguments of the failed call. :stacktrace is documented metadata and reaches every handler and exporter, and a frame that fails to match (FunctionClauseError) records its argument list, which for a request call is the headers: the Authorization value or the signature. Core.HttpClient now reduces each frame's argument list to its arity ({module, function, arity, location}). The reason text in that event, in "Request failed: ..." and in "Request exception: ..." is bounded (limit: 50, printable_limit: 2_048) and byte-identical to inspect/1 for anything inside the bounds.

  • HttpClient.get/3 sends a params map in key order. A map of more than 32 keys has no iteration order of its own, so a retry could send the same params in a different order, and a venue that signs the query string saw two strings. At 32 keys or fewer the order is unchanged.

  • Core.PollingFeed queues ticks in an Erlang :queue, not a list appended to. With thousands of symbols and a fetch slower than the interval the queue holds one job per symbol, and queue ++ [job] copied all of them on every tick inside the process that answers status/1. Same FIFO order, same one-fetch-at-a-time concurrency.

  • Core.PollingFeed's "delivered NOTHING" warning and its debug line bound the failure reason (limit: 20, printable_limit: 512). A fetch's {:error, reason} can be a whole decoded venue body, and the warning repeats every sweep for as long as an outage lasts. A raised exception's message is cut at 2_048 characters. status/1's last_error is still the term the fetch returned.

Changed

  • New public decisions behind assertions 12, 20 and 21, so the teeth tests exercise the suite and not a copy of it: AdapterContract.unsupported_fault/1 (an :unsupported endpoint must answer the atom {:error, :not_supported}: a string, a success, a raise or anything else is named), subscribed_push_fault/2 (assertion 20: wrong tag, or a payload that is not a Core.Types.* struct), and unserved_timeframe/1 with timeframe_fault/1 (assertion 21). The "under-declaring", "string refusal", assertion 20 and assertion 21 ContractTeethTest cases used to call the broken fixture and compare its answer, so no change to the suite could fail them. Behaviour of the assertions is unchanged, except that a failed assertion 12 now prints the answer it got.

[0.3.76] - 2026-10-11

Fixed

  • Core.PollingFeed no longer crashes when its failure reason holds a credential-shaped key. 0.3.7x made Notice.new/3 refuse a credential-shaped key at any depth of details, and the delivering-nothing notice put the fetch's raw {:error, reason} into details.last_error. A decoded venue error body keyed "signature" or "token" (an ordinary 401 shape), or a {:bad_fetch_result, answer} holding one, therefore raised ArgumentError out of record_failure/3 inside the feed, which restarted from its static start opts and lost every update_symbols/2 scope, on the exact path that reports an outage. A scalar reason (atom, string, number) travels as it was; anything structured is carried as bounded text (inspect/2, limit: 20, printable_limit: 512). status/1's last_error is unchanged and still the raw term.
  • Core.HttpClient catches an exit from the transport. Only a raise was caught, so a pool-checkout timeout or a transport process that was not running exited the CALLING venue process and skipped [:dp_exchange, :request, :exception]. It is now {:error, "Request exception: ..."} (wrapped in {:exchange_error, provider, _} when :provider is given), reported with kind: :exit.
  • Core.Fanout.resolve/1 and deliver/4 treat a name registered to a port as nobody. Process.whereis/1 answers a port for such a name and Process.info/2 raised on it inside the feed fanning out to every other subscriber.

Changed

  • AdapterContract assertions that could pass having checked nothing now fail.
    • Assertion 5 requires at least one active struct-typed endpoint to have answered with a struct (or a non-empty list of them). A fake refusing every endpoint, or answering {:ok, []} everywhere, passed having compared nothing.
    • Assertion 12 ("an active endpoint does not answer :not_supported") also fails when the fake RAISES. A raise is != {:error, :not_supported}, so a fake crashing on every endpoint passed. BREAKING ONLY FOR A FAKE THAT RAISES on a call the suite makes; the call shapes below are corrected so a conforming fake is not caught by them.
    • Assertion 17 refuses an empty selection: a credential_benefit: :required venue with no endpoint left to check (all exempt or declared away) passed having examined none.
    • subscribe/2, unsubscribe/2 and update_symbols/2 are now called with a list of symbols and add_payment_method/2 with a details map. By arity alone they were handed a bare symbol string, so a fake raised in Enum.map/2 or Map.merge/2 and the raise was taken for an answer.
  • New public decisions, so the teeth tests exercise the suite and not a copy of it: AdapterContract.credential_gated_endpoints/2 (assertion 17's selection), active_endpoint_fault/1 (assertion 12) and struct_answer?/1 (assertion 5). The earlier ContractTeethTest cases for these called the broken fixture and asserted what the fixture did, so no change to the suite could fail them.

[0.3.75] - 2026-10-11

Changed

  • Core.PollingFeed emits [:dp_exchange, :link, :up] on its first delivery. It was emitted only on recovery from delivering-nothing, so a feed that started and worked never emitted one and a dashboard keeping the last link event per provider saw a healthy venue with no :up. Once on the first success, and again only after a :down and its recovery (never per delivery). Core.Telemetry's moduledoc now states this, and the extra :stop metadata HttpClient has always set (retry_after_ms, reason, max_body_bytes).
  • Types.* new/1 refuses a bare float at the top level of any field and inside a list or tuple (the {price, quantity} levels of an order book). No field of any type is a float; a float has already lost digits and still prints as plausible. Integers and Decimal are unchanged, and free-form maps (details, line_items, metrics) may still hold one. BREAKING ONLY FOR A VENUE THAT PASSED A FLOAT TO new/1; struct literals are not checked, as before.
  • OrderBook.new/1 refuses levels that are not {price, quantity} pairs of Decimals (or integers), and a book not sorted best price first (bids may not rise, asks may not fall), which its moduledoc has always made part of the contract.
  • OrderBookDelta.new/1 refuses a level whose side is not :bid or :ask, or that is not a {side, price, quantity} triple. AuctionImbalance.new/1 refuses an :auction outside :opening | :closing. Both were stated in the typespecs and checked nowhere.
  • HttpClient.provider is typed atom() | String.t(). It said String.t() while every venue passes an atom, so dialyzer reported a venue's match on {:exchange_error, :gemini_private, _} as unreachable when it is the shape returned at runtime. Type only; behaviour unchanged.

[0.3.74] - 2026-10-11

Changed

  • Order.order_type gains :trailing_stop, :trailing_stop_limit, :market_on_close and :limit_on_close, the four types Capabilities already let a venue declare. A venue could say it accepts a trailing stop while Order.new/1 refused the order it then returned; Webull kept the round trip with a struct literal outside the type. Additive: every value accepted before is still accepted.

[0.3.73] - 2026-10-11

Changed

  • Instrument.new/1 refuses an :instrument or :status outside the closed vocabularies in its typespecs, raising ArgumentError (Validate.one_of!/3, as Fill, Trade and Position do). BREAKING ONLY FOR A VENUE THAT PASSED A RAW STRING ("online") OR A MISSPELT ATOM. A read of the five venue repos found every caller passing atoms from instrument_from/1 / status_from/1 or a local mapping onto :tradable | :delisted | :unknown, so none is affected. Instrument.status_from/1's doc named trading_disabled as a terminal state while the code answered :unknown; the doc is corrected (code unchanged: a disabled market may return, and reading it as delisted would be a guess).
  • Capabilities.new/1 requires measured_at and measured_against together (both or neither). A date naming nothing, or a source with no date, is an unlabelled claim, which CLAUDE.md rules out. All five venues and the reference venue declare both. A non-list historical_timeframes now raises a message naming the field instead of a bare ArgumentError from list subtraction.
  • Notice.new/3 refuses camelCase and run-together spellings of a credential name in details (apiKey, accessToken, clientSecret, XApiKey): names are compared with - and _ removed. It also refuses the nine names CredentialRedactionCheck already treats as secret (session_token, id_token, jwt, secret_key, api_passphrase, api_token, auth_token, bearer_token, private_key_pem), so the two lists no longer disagree. A grep of the venue repos found none used as a details key.
  • PollingFeed.update_symbols/2 raises FunctionClauseError in the caller for anything but a list or a MapSet. It is a cast: a nil or a bare map used to raise inside the feed, which restarted and lost every runtime scope. Both accepted shapes behave as before.
  • HttpClient reads a Retry-After of " 42 " (surrounding whitespace) and an HTTP-date with a one-digit day (Sun, 6 Nov 1994 ...), which both fell to the five-second floor. The :raw_status doc now says 429 is not handed back (it never was).

Added

  • HttpClient option :max_body_bytes (default 16 MiB, 16_777_216) bounds a response body as it is received. The request is streamed with Req's into: fun; the function counts bytes and halts the transfer once the total passes the limit, so an unbounded body from a venue, proxy or CDN no longer exhausts the caller's memory. An oversized response is {:error, {:response_too_large, limit}} (wrapped in {:exchange_error, provider, _} with a :provider), is never retried, logs a warning and emits [:dp_exchange, :request, :stop] with result: :error, reason: :response_too_large, max_body_bytes: limit. A body of exactly the limit is accepted. BEHAVIOUR CHANGE ONLY FOR A RESPONSE OVER 16 MiB, which was returned before; a venue needing more passes a larger :max_body_bytes. Nothing else changes: Req's decompression is the only step into: skips and this pipeline never enables it (no accept-encoding is sent), so decoding, decode_body: false and non-JSON bodies are as before. A transport that ignores :into (a custom :req_adapter) is checked after receipt instead. A non-positive-integer value raises ArgumentError.

Fixed

  • CredentialRedactionCheck fails a struct whose Inspect shows part of a secret. The sentinel was searched for whole, so "sk_live_****9f2a" (first or last characters shown) passed. The sentinel is now random hex and its first and last eight characters count.
  • LinkSafetyCheck sees Process.spawn(fun, [:link]) and :erlang.spawn_opt with a literal :link option as link-creating calls. [:monitor] alone is not flagged.

[0.3.72] - 2026-10-11

Changed

  • OptionContract.new/1 refuses a :right that is not :call or :put. BREAKING ONLY FOR A VENUE THAT BUILT A CONTRACT WITH ANOTHER VALUE ("call", :CALL, :long). Such a value built and then raised FunctionClauseError from in_the_money?/2 in the consumer's process. It now raises ArgumentError at construction, naming the field, like Fill, Trade and Position do for their closed vocabularies. A struct literal is unaffected.
  • Withdrawal.new/1, Conversion.new/1, StakingTransaction.new/1, FinancialStatement.new/1 and CorporateEvent.new/1 refuse a value outside their closed vocabulary (:status, :status, :type, :kind, :kind: exactly the values in each @type), raising ArgumentError. A grep of the five venue repos found no caller of new/1 on any of them (they build struct literals), so no venue is affected.
  • Telemetry.endpoint/1 strips userinfo before it drops the query and fragment. A password holding a raw ? or # (https://user:p?ss@host/path) left its front in the label. The authority (after ://, up to the first /) now loses everything up to its last @. A path-less URL with an @ in its query (https://host?email=a@b) now loses its host label (https://b); that is accepted in preference to leaking.
  • Types.Validate raises ArgumentError for an improper list in a field, naming the field, instead of FunctionClauseError from Enum.each/2.
  • Conformance assertion 2's capabilities/0 task waits 5_000 ms, not 1_000. The wait decided pass or fail under a loaded parallel suite in every venue package.

Performance

  • Timeframe.known/0 and nameable/0 return a list built at compile time. Both rebuilt and sorted it on every call, and nameable?/1 runs per candle on a consumer's write path. Same lists, same order.
  • VolumeProfile.point_of_control/1 takes the best level in one pass instead of sorting every level and reading the head. Same answer, same tie-break (highest merged volume, then lowest price by value).

[0.3.71] - 2026-10-11

Changed

  • Conformance assertions 16 (internal wiring) and 18 (link safety) no longer fail a venue for a bootstrap start_link of any arity. UnwiredCheck excused start_link/1 only, so start_link/0, start_link/2 and the /2 a default argument exports beside /1 were reported as unwired. LinkSafetyCheck excused start_link/1,2 and child_spec/1,2, so a GenServer with def start_link, do: GenServer.start_link(...) was flagged as linking. Both now excuse start_link and child_spec by name. These loosen; no venue newly fails.
  • LinkSafetyCheck also scans @behaviour :gen_server modules. Only GenServer was named, so the Erlang spelling of the same behaviour was never examined. A venue with an Erlang-style :gen_server module that links a child with no trap_exit now fails assertion 18. None of the five venues has one.
  • CredentialRedactionCheck (assertion 19) renders with limit: :infinity. The default limit of 50 truncated a struct wider than 50 fields, so a secret field past the cut was not in the output and the leak passed. A venue whose wide struct leaks a secret field now fails.

Fixed

  • PollingFeed applied a refusal or an error for a symbol removed in flight. Only the delivery arms checked scope, so {:refused, _} called on_refusal for a symbol the consumer had unsubscribed and latched it in refused until the next update_symbols/2, and {:error, _} counted toward failures_since_ok. A bulk {:refused, refusals} naming a symbol not in scope is likewise no longer reported or latched.
  • PollingFeed's [:dp_exchange, :link, :down] reason is bounded (limit: 20, printable_limit: 512); it carried the whole venue error term.
  • HttpClient.get/3 with raw_status: true quoted the whole body in its "HTTP 4xx" message. It is capped at 2 KB like every other body-bearing message.
  • HttpClient with rate_limit_per_endpoint: true and a URL with no path metered a bucket named "<provider> "; it is now "<provider> /".
  • CanonicalPair.to_canonical/2 with "" among a concat mapping's quotes matched every symbol and answered "BTCXYZ-". An empty quote is ignored.

[0.3.70] - 2026-10-11

Changed

  • BREAKING-ish: Instrument.instrument_from("future") is now :unknown, not :perp. A bare future is dated on some venues and perpetual on others (Coinbase's product_type: "FUTURE" covers both, told apart by future_product_details. contract_expiry_type, PERPETUAL or EXPIRING). Calling a dated future a perpetual was a nearby substitute. "swap", "perp" and "perpetual" stay :perp. instrument_type gains :future, and the new Instrument.instrument_from/2 takes the type plus the venue's expiry kind: "perpetual" gives :perp, "expiring" gives :future, anything else falls back to instrument_from/1. A venue that fed Coinbase's FUTURE to instrument_from/1 must move to /2 or its derivatives become :unknown.
  • Notice.new/3 refuses a credential-shaped key at any depth in details, through maps, lists and keyword pairs. It was top level only, so %{headers: %{authorization: ...}} reached a public issue. A venue that nests its decoded broker payload under details and trips this must drop or rename the key before building the notice.

Fixed

  • Test waits on real timers and spawned processes use 5 s. Every assert_receive in polling_feed_test.exs, contract_teeth_test.exs and fanout_test.exs that waited 100 ms to 2 s on a timer or a spawned process now waits 5 s. Waits for already-sent messages are unchanged.

  • VolumeProfile.point_of_control/1 raised on a price key spelled "NaN" or "Infinity". Decimal.parse/1 reads both in full, so the tie-break handed a non-finite Decimal to Decimal.compare/2, which raises, inside the consumer's process. Such a key is now unreadable like any other non-number: it loses the tie to a real price.

  • Conformance assertions 13, 20, 27 and 29 waited 0.5 to 2 seconds for a message from a spawned task or a fake's delivery. Those macros compile into every venue's suite, where a loaded CI machine turned a slow spawn into a failure. The margin is now 5 seconds; a passing run is not slower. No assertion newly fails a venue.

[0.3.69] - 2026-10-11

Added

  • order_args: on use DpExchange.Core.AdapterContract. A venue can now declare its own :order_request, :order_requests and :order_changes, each replacing the suite's generic default. Assertion 31's extension in 0.3.68 drove replace_order/4 and place_orders/3 with the generic %{price: 1} edit and a :gtc batch entry. Three fakes then refused them correctly under their venue's own rules: Coinbase's edit needs both size and price, Schwab's replace is a whole order, and Webull's batch refuses :gtc. An unknown key, or a value that is not a map, raises at compile time.

[0.3.68] - 2026-10-11

Changed

  • Conformance assertion 31 round-trips every order-minting call, not only place_order/3. Core.OrderRoundTripCheck.run/3 also calls place_orders/3, replace_order/4 and close_position/3 when each is declared active, and offers every id they mint to the fake's get_order/3 and cancel_order/3 (reads before cancels, each distinct id once). A refused place_orders/3 entry and a refused close_position/3 are allowed answers and not failures; a wrong-length batch, an unreadable batch entry, a replace_order/4 refusal and an un-allowed close_position/3 shape fail closed. A venue whose fake breaks on these calls will newly fail 31. Violations gain a :minted_by key. Still 31 assertions.
  • Order.new/1 refuses a side, order_type or status outside the atoms its types state (nil still allowed: "the venue's word, or nothing"). :canceled or "OPEN" built and matched no branch of a consumer's case. No venue calls Order.new/1; struct literals are unchecked as before.
  • DefaultRateLimiter.acquire/3 refuses a :timeout that is not a non-negative integer or :infinity with {:error, {:invalid_timeout, value}}. The server compared wait_ms > timeout in Erlang term order, so "30" never timed out (fail open) and :infinity always did.

Fixed

  • PollingFeed.update_symbols/2 crashed the feed on an enumerable that is not a list (Map.take/2 was handed it as given), losing every runtime scope on the restart.
  • FakeInjection queued outcomes popped in a Task were popped from the task's own copy. The test's queue was unchanged and the next call got the same outcome again. State now lives in an Agent the configuring process starts and links; the process dictionary holds only its pid, found through $callers, and each pop is one Agent.get_and_update/2. When the queue is exhausted behaviour is as before. A link ignores a :normal exit, so a monitor also ends the agent when the configuring process exits for any reason.
  • PollingFeed no longer dies with a raising :sink, :on_refusal or :on_notice. The callback runs inside the feed, so its raise restarted the feed and lost every update_symbols/2 scope. A raise, exit or throw is now logged as a warning naming the callback and the exception module (not its message, which can quote the payload) and the feed continues.
  • DefaultRateLimiter.penalize/3 raises ArgumentError naming a bad ms where it raised FunctionClauseError, and its doc no longer says it "cannot fail".
  • HttpClient.request/5 raises ArgumentError for a :retry_attempts or :retry_delay that is not a non-negative integer, before sending. "3" sorts above every integer, so it entered the retry branch and died on arithmetic in the calling venue process after the request had left.
  • DpExchange.Core.Config.find_override/1 skips a $callers pid on another node where Process.info/2 raised ArgumentError.
  • HttpClient.parse_rate_limit_headers/1 and the 429 Retry-After read accept atom header names where String.downcase/1 raised on them.

[0.3.67] - 2026-10-11

Found by reading unwired_check.ex, link_safety_check.ex, credential_redaction_check.ex, venue.ex, adapter_contract.ex and usage-rules/adapter.md line by line. None of this has been run: it was written without mix, so the suite, mix quality and the five venues' contract tests are still to be run before release. Each venue was read against the new and stricter checks and none is expected to fail; the reading is recorded in docs/reference/core/assertion-coverage.md.

Added

  • Assertion 31, "fake order round trip". For a venue whose place_order/3 is active, the suite places an order on the fake and hands the returned id to the same fake's get_order/3 (must answer {:ok, %Order{}}) and cancel_order/3 ({:ok, %Order{}} or {:ok, :cancelled}), each only if active. dp_exchange_gemini's fake refused its own ids (gemini issue #5) and every assertion stayed green, because none used what place_order/3 returned. The decision is the new public DpExchange.Core.OrderRoundTripCheck.run/3, tested against fakes that fail it. An active place_order/3 that answers anything but an order fails the group rather than skipping it. get_order/3 and cancel_order/3 take their endpoint_opts: entry where the venue declared one and the options the order was placed with where it did not.

Changed

  • LinkSafetyCheck sees a link through a module held in a variable, and counts Task.async and Task.async_stream (which link the caller to the task) as links. It matched only a literal-atom module, so state.socket_module.start_link(opts) — how a package lets its tests inject its socket — linked a child with no trap_exit and passed. A process-behaviour module with no abstract code is now {:error, {:no_abstract_code, module}} instead of a MatchError, and the abstract code is read for process-behaviour modules only, where it was loaded for every module under lib_root and discarded.
  • CredentialRedactionCheck refuses a scanned module it cannot load with {:error, {:not_loadable, module}}. It answered "not a struct" and dropped it, so a Credentials that leaks and one never examined looked the same.
  • UnwiredCheck excuses macros (MACRO-<name> exports). An expanded call leaves no edge in the call graph, so a macro used inside lib/ was reported as unwired. A macro nothing uses is therefore not reported. Its edge lookup is also indexed by callee, where each exported name scanned the whole edge set.

Fixed

  • usage-rules/adapter.md had assertion 7's section in the middle of assertion 17's, with 17's account of its exemptions running on beneath the wrong heading.
  • Prose said fifteen secret names; CredentialRedactionCheck checks twenty-seven. adapter.md and the check's moduledoc now list them all.
  • Prose said dp_exchange_schwab's Feed dials its Streamer unconditionally from init/1. That was fixed in 9973c0d on the day it was written; adapter.md and a comment in AdapterContract still described it as current, against LinkSafetyCheck's own moduledoc.
  • UnwiredCheck's limits are stated: a function reached only through a {Mod, :fun, args} tuple is reported though it runs, and two dead functions that call each other are not reported.

[0.3.66] - 2026-10-11

Changed

Constructors and options that accepted a plausible wrong value now refuse it, found by reading lib/ against the fail-closed rule. A caller that relied on any of these must now pass the value the type states.

  • Capabilities.new/1 checks the scalar fields' kind. supported_quotes must be a list of non-empty strings ([:USD] built the right pair strings and failed every in ["USD"]), max_candles_per_request a positive integer or nil (0, "300" and 300.0 were accepted), measured_at a Date or nil, and measured_against a non-empty string or nil. A string date or an empty measured_against read as provenance and carried none. All five venues declare these correctly.
  • Fill.new/1, Trade.new/1, Position.new/1, OrderLeg.new/1 refuse a value outside the vocabulary their typespec states. side must be :buy | :sell (Trade also nil) or :long | :short for a Position; Fill.liquidity :maker | :taker | nil; OrderLeg.ratio a positive integer and position_effect :open | :close | nil; Trade.broken a boolean. Position built :buy was read by a consumer matching :long | :short as no side or the wrong one. Venues build these as struct literals, so none is affected. Validate.one_of!/3 and Validate.positive_integer!/2 are new and public.
  • ApprovedAddress.new/1 refuses a status other than :pending | :active | :rejected, and ApprovedAddress.usable?/2 answers nil ("unknown, not usable") for a struct literal carrying any other, where it raised FunctionClauseError in the caller's process.

  • PollingFeed refuses an unusable duration at start. interval_ms and fetch_timeout_ms must be positive integers and start_delay_ms a non-negative one; anything else stops the feed with {:invalid_option, key, value}. interval_ms: 0 re-polled the venue as fast as the fetch returned, and a negative or non-integer value raised inside init/1 naming send_after/3 rather than the option.
  • Fanout.deliver/4 raises on a :max_queue_len that is present but not a positive integer. queue_len >= "10000" is false for every queue, so a string or :infinity switched back-pressure off exactly as nil once did (nil still means the default).
  • HttpClient.build_auth_headers/5 raises ArgumentError for a missing, nil or empty credential under :bearer, :basic and :hmac_sha256. "#{nil}" is "", so an unset config value built Authorization: Bearer and the request went out looking signed.
  • DpExchange.venue/1 refuses a name containing . or /. Macro.camelize/1 turns / into ., so "schwab/capabilities" resolved to any module nested under a venue that exports capabilities/0.
  • CanonicalPair.to_exchange/2 returns a canonical symbol with no base ("-USD") uppercased and untouched, where it returned the bare quote "USD" on a concat venue.
  • Core.AdapterContract group 1 fails when fake: or sample_pairs: is missing. Groups 14, 20, 23 and 25 skip on an empty sample_pairs: and 29 loops zero times, so a venue configured without them passed the suite having checked almost none of it. All five venues and the reference venue pass both.
  • CredentialRedactionCheck recognises more secret field names: x_api_key, signing_key, cookie, session_token, id_token, jwt, secret_key, api_passphrase, api_token, auth_token, bearer_token, private_key_pem. A struct with defstruct [:secret_key] printed in cleartext and passed. credentials is left out on purpose: it normally holds a struct that is itself redacted.

Fixed

  • HttpClient wrote the full request URL to the log. request/5's debug line printed the URL whole and the retry warning kept user:pass@ and any #fragment, so a venue that authenticates in the query string or the URL's userinfo wrote its credential to the log on every request. Both now use Telemetry.endpoint/1, as the telemetry metadata already did.
  • HttpClient.get/3 undid decode_body: false. The option (0.3.65) hands back the bytes so a JSON number keeps its digits, and get/3 then decoded them with native floats, losing the digits one function later for the only caller that had asked otherwise. With decode_body: false it now decodes with floats: :decimals, so a number is a Decimal.
  • PollingFeed crashed on a malformed bulk refusal list. A {:refused, refusals} with an element that is not {symbol, reason} raised FunctionClauseError in the feed, and the restart lost every runtime update_symbols/2 scope. It is now a failed cycle, {:bad_fetch_result, {:refused, refusals}}.
  • Notice.new/3 raised Protocol.UndefinedError for details keyed by a tuple or pid. Only an atom or a binary can name a credential, so only those are tested.
  • usage-rules/testing.md's conformance example had no fake:, which the suite requires.
  • The install snippet pinned ~> 0.1.0. That requirement resolves to >= 0.1.0 and < 0.2.0, so the README's {:dp_exchange_core, "~> 0.1.0"} installed a release from before nearly everything here. README, usage-rules.md, the DpExchange moduledoc and the status section above now say ~> 0.3.0.

[0.3.65] - 2026-10-11

Added

  • HttpClient.request/5 passes :decode_body through to Req. Req's default JSON decode turns every number into a float, so a venue that types an amount as a JSON number lost every digit past ~17 significant ones before its own decoder saw it (measured: 1234.567890123456789 arrives as 1234.567890123457). decode_body: false returns the bytes for the venue to decode with Jason.decode(body, floats: :decimals). Absent, nothing changes.

[0.3.64] - 2026-10-10

Changed

Two defaults that failed open, found 2026-10-10 while checking the docs that described them.

  • Instrument.new/1 without :status is now :unknown, not :tradable. An instrument built without a status was reported tradable on no evidence — the substitution status_from/1 exists to refuse. Every venue package already passes a status on its real path; a caller that relied on the default must now pass status: :tradable itself.
  • HttpClient.build_auth_headers/5 raises ArgumentError on an unknown auth_type. It returned [], so a typo'd scheme sent the request unauthenticated.

Fixed

Documentation corrected to match the code, found 2026-10-10 by checking each claim against the module it describes. No behaviour changed.

  • The contract is 85 required callbacks and 3 optional, not "almost all optional". usage-rules.md, usage-rules/adapter.md, the README and many Core.Venue callback docs ("Optional, and ...") said a package implements what its venue serves and may omit the rest. Only list_instruments/1, quantization/1 and coverage_by_kind/1 are in @optional_callbacks; every other callback must be defined, and a venue that does not serve it declares it :unsupported and answers {:error, :not_supported}. Core.Venue's moduledoc now says what "Optional" means there.
  • usage-rules/feeds.md said back-pressure "drops oldest". It drops the newest: a sender cannot remove what is already in a subscriber's mailbox, and Core.Fanout stops sending once the mailbox passes the bound (10,000 by default). Core.Venue.subscribe/2 already said so; the guide now agrees.
  • AdapterContract moduledoc said "Twenty-one groups". There are thirty.
  • HttpClient: request/5's options now list :rate_limit_blocking, :weight and :max_retry_after_s, which were read but undocumented. :account_id, :user_id and :operation are documented as forwarded to the limiter (the default limiter ignores them), where the docs claimed account-aware limiting and an :operation default of "default" that nothing set. build_auth_headers/5 documents the function form of auth_type and that an unrecognised scheme yields []. request_error said "Three shapes" and listed two.
  • DefaultRateLimiter moduledoc said every callback returns {:error, :not_started}. record/3 and penalize/3 cannot fail and answer :ok. inspect_provider/2 is documented as returning {:ok, map}, as its spec says.
  • PollingFeed.start_link/1 now documents its options and their defaults.
  • Types.Validate and usage-rules/adapter.md now say new/1 also refuses a NaN or infinite Decimal and a NaiveDateTime in any field.
  • usage-rules/symbols.md said quotes must be listed longest-first. CanonicalPair has sorted them itself for some time; the order no longer matters. SymbolNormalizer said malformed input degrades to "UNKNOWN"; it comes back uppercased. CanonicalPair.to_exchange/2 said a bare symbol comes back unchanged; it is uppercased and alias-reversed.
  • Instrument.new/1 said it normalises and defaults conservatively. It does neither: it validates, and an omitted status is :tradable. Pass status: :unknown when there is no status to report.
  • RateLimitBehaviour no longer calls itself "future" or says "the host" records on HttpClient's behalf; usage-rules/environments.md says environment resolution is each venue package's, built on Core.Config.

[0.3.63] - 2026-10-10

Fixed

All found 2026-10-10 by reading the conformance suite. Both tighten it: a venue that was passing only because the check could not fire may now fail.

  • AdapterContract assertion 7 (purity) could not reject a dependency. It identified a module's owner by looking for /deps/<name>/ in its path, but Mix builds and loads every dependency from _build/<env>/lib/<name>/ebin, so nothing ever matched and every dependency module was permitted; only a module that exists nowhere was refused. It also read mix.exs deps with for {dep, _rest} <- deps, which silently drops any dependency declared with both a requirement and options. Now decided by AdapterContract.permitted_apps/2 and foreign_modules/3 (public, tested): a module belongs to the application it is built into, and its own app, every declared dependency and their runtime dependencies are permitted. A venue linking a library it neither declares nor receives through a declared dependency now fails 7.
  • AdapterContract assertion 17 (credential gate) never exercised place_order/3 or replace_order/4. Its credential-stripped call rebuilt the argument list without the per-callback overrides the other assertions use, so it sent place_order(%{}, "BTC-USD", opts); a fake matching on its order raised, a raise is not {:ok, _}, and the assertion passed without reaching the credential. Both calls now come from one table, AdapterContract.arg_shape/3. A fake whose order writes succeed with credentials stripped, on a credential_benefit: :required venue, now fails 17.
  • Eleven credentialed callbacks were called with a symbol where the credential belongs. list_payment_methods/2, get_payment_method/3, get_notional_balances/3, list_custody_fees/2, get_transactions/2, place_orders/3, cancel_all_orders/2, preview_order/3, preview_replace/4, close_position/3 and get_trade_volume/2 were missing from a hand-written list, so every fake-driven assertion (5, 11, 12, 17) called them with the wrong arguments and tolerated the raise. The set is now read from Core.Venue's @callback specs (AdapterContract.credentialed_callbacks/0), and place_orders/3, preview_order/3 and preview_replace/4 are shaped as the orders they take.
  • Every other non-symbol callback was called with arguments of the wrong type. By arity alone stake/3 got "1h" where its Decimal amount belongs, withdraw/5 five keyword lists, get_fx_rate/3 "1h" for its DateTime, quantization/1 a keyword list for its symbol; each fake raised and the raise was accepted. Sixteen callbacks now carry their Core.Venue spec's own argument kinds (asset, amount, network, address, FX pair, instant, statement kind, watchlist name and symbols, account id), and a test pins every callback's shape to its arity.
  • Assertion 20 subscribed without the venue's endpoint_opts, unlike 26, 27 and 29, so a fake that needs an option to subscribe failed there alone. It now passes them.

[0.3.62] - 2026-10-10

Fixed

All found 2026-10-10 by reading the code paths.

  • Types.Validate: a NaN or Infinity inside a plain map was not refused. The scan descended into lists and tuples but not maps, so VolumeProfile's buy_at_price / sell_at_price, OptionChain.expiries (Decimal strikes as keys) and StakingBalance.by_provider carried a NaN straight past new/1, and VolumeProfile.point_of_control/1 then raised Decimal.Error in the consumer's process. Map keys and values are now scanned; structs other than Decimal/NaiveDateTime are still not descended into.
  • HttpClient: the [:dp_exchange, :request, :exception] event lacked :stacktrace and :status, both documented in Core.Telemetry. A handler matching on either never saw them. :stacktrace is the rescued stacktrace and :status is nil. get/3's @spec also named {:error, String.t()} though it returns {:exchange_error, provider, reason} whenever :provider is given; it now uses request_error().
  • Telemetry.endpoint/1: a raw @ in a URL password left the tail of the password in the label. https://user:p@ss@host/x was stripped to https://ss@host/x. Userinfo is now stripped up to the last @ of the authority.
  • HttpClient.get/3: a list query value was concatenated into one word. to_string/1 turned [symbols: ["BTC", "ETH"]] into symbols=BTCETH, a well-formed request for a product that does not exist. A list, tuple or plain-map value now raises ArgumentError; the venue chooses repeated keys or a comma join and passes the string. Keys are now form-encoded as well; they were interpolated raw.

[0.3.61] - 2026-10-10

Fixed

All found 2026-10-10 by reading the code paths.

  • PollingFeed: a symbol removed while its tick was queued, then re-added, was never polled again. Skipping the queued tick left the symbol counted as scheduled, so the re-add set no timer. The skip now clears it, like every other exit from the chain.
  • PollingFeed: a per-symbol event for a different symbol is a failure. It was delivered under the venue's spelling while coverage/1 reported the requested symbol as delivered. It is now recorded as {:unrequested_symbols, [symbol]}, matching the bulk path.
  • PollingFeed: a fetch result outside the contract no longer crashes the feed. :ok, nil, {:ok, nil} or a bulk event that is not a map with a :symbol raised in the feed process, and the restart lost runtime update_symbols/2 scope. They are recorded as {:bad_fetch_result, _}.
  • PollingFeed telemetry names :provider, not :label, the same as its notices.
  • Fanout no longer raises on a subscriber pid on another node. Process.info/2 and Process.alive?/1 raise for a remote pid, so one remote subscriber crashed the feed for every subscriber. A remote pid is sent to without the queue bound, which is local only.
  • Types.*.new/1 refuses NaN, infinity and NaiveDateTime inside lists and tuples too. The check was top-level only, so an OrderBook level {NaN, qty} built.
  • CanonicalPair.to_exchange/2 raises when two exchange codes alias one canonical code. It picked whichever map iteration reached first.
  • Conformance: the two round-trip assertions fail on an empty sample_pairs: or supported_quotes, instead of looping zero times and passing.

[0.3.60] - 2026-10-10

Added

  • Types.Conversion status :cancelled. It means withdrawn before it settled. Coinbase reports TRADE_STATUS_CANCELED separately from TRADE_STATUS_EXPIRED, and with no value to carry it a cancelled conversion was stamped :expired: a window that closed, which is a different event.

Changed

  • Venue.unsubscribe/2 says it is feed-wide. The call names no subscriber, so unsubscribing a symbol stops it for every process subscribed to it on that venue. Every package already behaved this way, but the contract never said so.

[0.3.59] - 2026-10-10

Changed

  • Venue.cancel_order/3 is typed result(Order.t() | :cancelled). It said Order.t(), which three of five venues never returned: Coinbase, Webull and Schwab cancel endpoints answer success with no order data. :cancelled is now the declared answer for that case. A caller wanting the order asks get_order/3.

[0.3.58] - 2026-10-10

Fixed

  • Assertion 5 called replace_order/4 with options in every position. There was no credentialed arity-4 shape, so every venue's fake raised. The raise was counted as conforming until 0.3.57. It now passes an order id and a %{price: _} change, the way place_order/3 now gets an order request.

[0.3.57] - 2026-10-10

Fixed

  • Conformance assertions that could not fail now can.
    • Group 7 (purity) treated a module found nowhere as permitted, which is exactly a host-app module the package never declared.
    • Group 11's "no facade return value carries a process" walked the :unsupported endpoints, which answer {:error, :not_supported}. It now walks the active ones, on the fake.
    • Group 1's optional-callback check asserted is_boolean/1, which is always true. It now fails a callback exported at the wrong arity.
    • Group 3's runtime_id/0 check accepted nil and any atom. It now requires the module's underscored last segment.
    • Group 28 could filter every call out before the venue was loaded, and now loads it first.
    • Group 26 called a missing fake, and raised a bare MatchError on fewer than two pairs.
    • Group 5 counted a fake that raised as conforming.
  • Timeframe.aligned?/2 called a millisecond-precision boundary unaligned. It compared the microsecond tuple {0, 3} with {0, 0}, and from_unix!(ms, :millisecond) and .000Z strings both produce {0, 3}. boundary/2 rounded a pre-1970 time into the next bucket. It now uses Integer.floor_div/2.
  • Types.*.new/1 refuses NaN, Infinity and NaiveDateTime in any field. A nil check let Decimal.new("NaN") build, and that later raises inside Decimal.compare/2 in the consumer.
  • Notice.new/3 refuses a nil provider and a non-DateTime :at, and reads - as _ in credential keys (api-key, X-Api-Key). It also refuses client_secret, x_api_key, app_secret, signing_key and cookie.
  • Telemetry.link_down/2 raised on a non-string reason inside the venue's feed. A raw reason is now inspected. endpoint/1 strips userinfo and the fragment as well as the query.
  • Capabilities accepted any Decimal as max_leverage, including 0, a negative and NaN. It must now be positive and finite.

Documentation

  • usage-rules/testing.md said 23 assertion groups; there are 30. The Telemetry moduledoc now lists the count and conditional bytes measurements each link event actually emits.

[0.3.56] - 2026-10-10

Fixed

  • 0.3.55 made an unsignable request ask the limiter first. A request with no credentials spent a token. With no limiter running, it reported "Rate limiter unavailable" in place of {:missing_credentials, _}, the reason that names the fix. Every venue's "no credentials never reaches the network" test caught it. A request is now signed first, and an unsignable one fails before the limiter. On a blocking limiter it is signed again after the wait, and only that signature is sent, which keeps the 0.3.55 nonce-ordering fix. A non-blocking limiter never waits, so its first signature is the one sent.

[0.3.55] - 2026-10-10

Fixed

  • HttpClient signed a request before the limiter's wait, not after. A header function was called first and a blocking limiter waited afterwards. Two callers on an incrementing nonce (dp_exchange_gemini) were then released in either order, and the earlier signature reached the venue second and was refused as a replay. A time-based nonce could outlive its 30 s window the same way. Each attempt now reserves its tokens, then signs, then sends, and a request the limiter refuses is never signed.

[0.3.54] - 2026-10-10

Fixed

  • HttpClient: an endpoint bucket that refused spent the provider bucket's token. With rate_limit_per_endpoint: true, the provider was reserved before the endpoint refused, for a request never sent. On the non-blocking path every bucket is now checked first, and none is reserved unless all have room.
  • HttpClient: Retry-After in HTTP-date form was ignored, and no value was bounded. A date got the 5 s floor, and the venue was re-hit inside its own penalty. Retry-After: 86400 held the bucket for a day. Both forms are read now. A date already past is 1 s, and every value is clamped to :max_retry_after_s (600 by default, chosen rather than measured).
  • HttpClient: retry classification matched the message anywhere. A 5xx whose body quoted "Client error (4" was treated as permanent. The match is now a prefix on the message this module builds. A 3xx (redirect: false) is no longer retried, because the same request gets the same redirect.
  • PollingFeed: a throw in a fetch killed the feed. safely/1 caught exits and rescued exceptions only. A throw is now a failed fetch, {:throw, value}.
  • PollingFeed.status/1 reported delivering: true for a feed dark for hours. It read last_ok as ever-delivered. It now uses the same window as coverage/1.
  • PollingFeed: a refusal was reported every cycle. The typedoc says once. on_refusal now fires once per refusal and again only after the symbol has delivered in between. The symbol stays polled, so a pair listed later comes back by itself.
  • PollingFeed: a queued symbol unsubscribed before its turn was still fetched. It cost a venue request for a result that was then dropped. It is now skipped.
  • DefaultRateLimiter: per-endpoint keys were never removed. A path carrying an id minted a key per request. Idle keys are now swept every minute. A tat at or behind now is exactly an absent one, so no answer changes.
  • Fanout.deliver/4: one process registered as a pid and by name got each message twice, and its transition was reported twice. Subscribers are now resolved and deduplicated first.
  • CanonicalPair: a lowercase mapping never matched. Quotes and alias keys are now uppercased, like the input.
  • HttpClient.rate_limited_request_options is a keyword list only. A map was in the type, and every reader of it raises on one.

Documentation

  • :retry_attempts applies to every method, POST and DELETE included, and the venue decides per call whether a write may be repeated. The venues already do this: a write with an idempotency key may retry, and one without is sent once.
  • DefaultRateLimiter's burst is the count of back-to-back requests from an idle bucket. burst: 0 makes even the first request wait. A weight above burst never fits without waiting.

[0.3.53] - 2026-10-05

Fixed

  • A slow subscriber resumes only once it has drained below half the bound (Fanout.resume_below/1), not the moment it is one message under. A consumer hovering at the bound flipped :dropping/:resumed on every message, and each flip was a :degraded or :info notice sent into the same full mailbox. In dp_crypto_management on 2026-10-04 that meant queue lengths of 10_002, 10_000, 10_002 against a 10_000 bound, and 5,700 notices in five minutes across four venues, up to 2,062 a minute from one. One stall now costs one :dropping and one :resumed. Every venue's feed delivers through Fanout.deliver/4, so this covers all five.

[0.3.52] - 2026-10-03

Fixed

  • Concurrent non-blocking requests can no longer overspend a bucket. The non-blocking path called check/3, which reserves nothing, then sent the request, then called record/3. Concurrent callers all passed the check before any of them recorded. On 2026-10-03, a consumer fetching Webull bars in parallel per symbol sent two requests 250 ms apart through a 1/s bucket with a burst of 1, and drew a 429. Both paths now reserve atomically before sending. The non-blocking path uses acquire/3 with a zero timeout, which reserves only if there is room now and commits nothing otherwise; check/3 is asked only afterwards, for the wait to report. Nothing is recorded after a request, because every attempt is already counted by its reservation. Measured with the real limiter: one of eight simultaneous requests goes out, where before there were two.
  • A venue's Retry-After now holds the whole bucket, not only the request that drew it. After one Webull 429 with retry_after=5s, requests paced at the bucket's own 1/s drew 26 more in 31 s. HttpClient now holds the most specific bucket (the endpoint's, under rate_limit_per_endpoint) for the stated time through the new optional RateLimitBehaviour.penalize/3, which DefaultRateLimiter implements. A limiter without it keeps working unchanged.

[0.3.51] - 2026-10-02

Fixed

  • A blocking request is metered once, not twice. With rate_limit_blocking: true, HttpClient acquired from the limiter, which reserves the request's tokens, then recorded the same request again after sending it. Every venue feed that paces itself this way ran at half its declared ceiling: Webull's at 30 requests a minute against a declared 60. Only the non-blocking path records now, because check/3 reserves nothing.

Added

  • rate_limit_per_endpoint: true also meters a request in a bucket of its own endpoint, keyed "<provider> <path>", beside the provider's bucket. The path is the URL's, without its query. It is for venues that state a per-endpoint limit beside a global one. Webull's pages say "1 request per second per App Key" and "600 requests per minute", and one shared bucket at 60/60s with a burst of 60 let a backfill send a minute's budget to one endpoint at once. A consumer's log on 2026-10-02 showed 45 429s with retry_after=5s on Webull's crypto bars.

[0.3.50] - 2026-10-02

Added

  • Quote.volume_window says what quantity Quote.volume is: :print (one trade's size, to sum), :running_total (cumulative since the venue's own reset, to difference), or :rolling_24h (a trailing day, which no interval's volume can be derived from). It is nil when volume is nil. volume used to be all three, depending on the venue. dp_crypto_management summed Coinbase's rolling 24-hour total into candles as if it were one print, and a pair's 15-minute average volume read about 1000× too high (issue #42). Quote.new/1 refuses an unknown window, and refuses a window on a quote with no volume. Quote.volume_windows/0 lists the values.
  • Conformance assertion 30: a Quote from a venue's get_price/2 that carries a volume must name its window. Measured on dp_exchange_schwab before its fix: 1 failure.

[0.3.49] - 2026-10-01

Fixed

  • A Notice built without a message now carries one, made from its provider, its kind and details.reason when present, e.g. "coinbase link down: {:remote, :closed}". Several venues built link_down, link_up and other notices with no message, so a host logged link_down: (no message) with the reason buried in details. dp-exchange-core issues #33 and #41 were the same gap found one venue at a time. An explicit message: nil is kept, since a venue passes it on purpose to say "the venue sent no text".

[0.3.48] - 2026-09-29

Fixed

  • A bulk PollingFeed with no symbols no longer fetches, or records a failure. Each tick ran the fetch with [], and the venue's honest {:ok, []} was recorded as :empty_response, so a feed started before its consumer subscribed, or left idle after the last unsubscribe, escalated to "delivering nothing" and warned every interval. The tick now reschedules without asking, and the first tick after update_symbols/2 fetches.

[0.3.47] - 2026-09-29

Fixed

  • An error message no longer carries a venue's whole response body. A 5xx body, often a proxy's HTML page of any size, went into the message, into a warning on every retry and into every caller's error tuple. It is now excerpted at 2 KB, cut on a character boundary and marked with the full size. raw_status: true still hands a 4xx back whole.
  • A negative Retry-After is no longer passed on as a wait. The caller was told to "retry after -30s" and the rate-limit telemetry carried a negative retry_after_ms. It now falls back to the same 5-second floor as a missing header.

[0.3.46] - 2026-09-28

Added

  • HttpClient.request/5 accepts headers as a function, called again for every attempt. A venue that signs with a timestamp or a one-time nonce could not be retried byte for byte. Robinhood's signature is accepted for about 30 seconds, and Gemini and Webull sign a nonce the venue refuses to see twice. A retry after a timed-out first attempt re-sent the same signed headers, so it was either stale or a replay. The venue refused it as unauthorised, a credential problem the caller does not have. The function may return a header list, {:ok, list} or {:error, reason}. An error is returned at once and never retried. Venue packages opt in at their signing call sites. See the signed_headers type.

[0.3.45] - 2026-09-28

Added

  • Conformance assertion 29: a lower-case subscription is delivered under the canonical symbol. Every venue's real facade upper-cases subscribed symbols, because its Feed drops a payload for a symbol it does not want. Every fake took them as given, so btc-usd delivered nothing against the fake while the real package delivered BTC-USD. The assertion subscribes each fake to its first sample pair in lower case and requires delivery under the canonical symbol. Checked against dp_exchange_coinbase: it fails on that fake before its fix and passes after. ReferenceVenue upper-cases too.

[0.3.44] - 2026-09-28

Fixed

  • PollingFeed no longer delivers or counts a symbol that is no longer wanted. A fetch runs in a task, and update_symbols/2 can remove its symbol while it is in flight. The result that came back afterwards still reached the sink, which delivered to a consumer that had just unsubscribed, and went into last_ok. So coverage/1 answered :internal_poll for a symbol nobody wanted. Results are now applied only for symbols in the current set. A bulk answer in which no event names a wanted symbol is recorded as {:unrequested_symbols, names} rather than dropped silently, because that is how a venue whose event symbols are spelled differently from the requested ones would otherwise look: exactly like a quiet market. dp_exchange_robinhood and dp_exchange_schwab, the two packages that run a PollingFeed, pass their full suites against it.

[0.3.43] - 2026-09-28

No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.

[0.3.42] - 2026-09-28

Fixed

  • req is now required at ~> 0.7, because every request failed on Req 0.5 and 0.6 from 0.3.38 on. 0.3.38 made HttpClient pass :request_timeout so that :timeout bounds the whole response. Req added that option in 0.7.0, and the requirement still said ~> 0.5. So a consumer that resolved Req 0.5.x or 0.6.x, which the requirement allowed, got unknown option :request_timeout from every call. This package's own lock was on 0.7.4, so nothing here saw it. script/check_dependency_floor.sh found it (29 failures at Req 0.5.0). The suite passes at the new floor, Req 0.7.0. 0.3.38 through 0.3.41 carry the wrong requirement. A consumer on Req 0.5 or 0.6 should not use them.
  • The dependency floor check now tests an or requirement at its first clause. "~> 2.0 or ~> 3.0" (decimal, since 0.3.41) matched neither of the script's patterns, so it resolved the newest 3.x and the 2.0 floor went untested. It now pins == 2.0.0, and the suite passes there.

[0.3.41] - 2026-09-28

Changed

  • decimal may now resolve to 3.x (~> 2.0 or ~> 3.0). Decimal 3.0 makes the mitigations for CVE-2026-32686 the default: an input such as 1e1000000000 is rejected rather than materialised. ~> 2.0 stopped a consumer from taking that version. The full suite passes on 3.1.1. Under 3.x, Decimal.parse/1 refuses more than 34 digits and Decimal.to_string/2 raises past 6,178 characters, so the venue packages were fuzzed on 3.1.1 before widening their own constraint.

[0.3.40] - 2026-09-27

Added

  • Conformance assertion 28: a streaming call answers when no feed is running. subscribe/2, unsubscribe/2, update_symbols/2, subscribe_notices/1 and coverage/1 must return a value, never exit the caller. On 2026-09-27 three of five venues exited :noproc there while two answered {:error, :feed_not_started}, so the same consumer code survived on two venues and crashed on three. Unlike the other streaming assertions, this drives the real facade, down the no-feed path only, so it starts nothing and reaches no network. A venue that does not select its feed with :feed passes its own absent_feed_opts:. Checked against dp_exchange_coinbase: it fails on the facade before the fix and passes after.

[0.3.39] - 2026-09-27

No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.

[0.3.38] - 2026-09-27

Fixed

  • :timeout now bounds the whole request, as its doc always said. It was passed only as Finch's receive_timeout, which restarts on every chunk. A response that trickled in, from a slow venue, a proxy or a degraded link, kept a facade call running without limit. A test measured it: a request with a 300 ms timeout, fed one byte every 100 ms, was still running at 3 s. :timeout is now also passed as Finch's request_timeout, the whole-response timer, which defaulted to :infinity. Every venue package reaches its venue through this client, so this covers all of them.

[0.3.37] - 2026-09-27

Fixed

  • A negative x-ratelimit-reset header no longer stalls the calling process. parse_rate_limit_headers/1 runs on every response. It treated any value at or below one year as a delta in seconds, negatives included, and passed it to DateTime.add/3. That function computes a date for any offset, and for an extreme one it took longer than 4 seconds (measured 2026-09-27 at 10^20, 10^22 and 10^24 seconds). A negative reset now answers reset_time: nil, the same answer an unreadable reset already gives.

[0.3.36] - 2026-09-27

No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.

[0.3.35] - 2026-09-25

Documentation

  • get_trades and get_trade_history are documented as oldest first, by :timestamp, as get_historical_prices is. No venue ordered them before. dp_exchange_coinbase 0.3.50, dp_exchange_gemini 0.2.55 and dp_exchange_webull 0.4.68 now sort them, keeping the venue's own order for equal timestamps. As with the candle rule, there is no conformance assertion, because the fakes answer with one trade at most.

[0.3.34] - 2026-09-25

Documentation

  • get_historical_prices is documented as oldest first, by opened_at. Every venue that serves candles already sorted this way except Schwab, which does from dp_exchange_schwab 0.2.53. It is stated in the callback's doc and not added as a conformance assertion, because the venue fakes answer with at most one candle, which satisfies any ordering. That check would run without being able to fail.

[0.3.33] - 2026-09-24

Added

  • Assertion 5 has a check of its own. It was listed — "return types — Core.Types.* with Decimal numerics and DateTime timestamps" — and described as cross-cutting, and the parts other groups checked were real: Decimal prices in 14, DateTime times in 23, an attributed Balance in 24. Nothing asked what 5 actually states: does an active endpoint answer with the Core.Types struct its Core.Venue callback promises?

    Found in dp_exchange_schwab, whose get_order/3 and get_orders/2 — typed result(Types.Order.t()) — returned the venue's raw JSON map. So did its fake, which is the only thing this suite drives, so nothing here could see it.

    The expectation is read from Core.Venue's own @callback specs by the new public promised_types/0, using Code.Typespec.fetch_callbacks — not written as a table, so a callback added later is checked the day it is added and the list cannot drift from the contract it restates. 44 of the 88 callbacks promise a Core.Types struct. A success of the wrong kind fails: a raw map, or a list holding anything but the promised struct. Refusals are left to assertions 6 and 12. Break-verified: putting schwab's fake back to a raw map fails it, by name. Verified against all five venue packages on a path dep.

Fixed

  • ReferenceVenue.get_historical_prices/4 returned a Quote, not a Candle — caught by assertion 5 on its first run, the fourth time a new assertion has found a bug in Core's own worked example.

[0.3.32] - 2026-09-23

Fixed

  • PollingFeed.start_link(sink: nil) started, then crash-looped. :sink is read with Keyword.fetch!/2, which proves only that the key is present — sink: nil passes it. The feed started cleanly and died with {:badfun, nil} on its first successful fetch, measured; under a supervisor that restarts straight back into the same death, with a message naming apply_result/3 rather than the option that is wrong. It is the shape this module's init/1 already records for :on_refusal, arriving through the one required function the DpExchange.Core.Config.opt/3 fix could not reach — a required option has no default to fall back to.

    init/1 now refuses a :sink that is not a one-arity function, and a :fetch or :fetch_all that is present but not one, with {:invalid_option, key, value} — at start, beside the existing :no_fetcher refusal. The existing test asked about an ABSENT sink; the new ones ask about a present one.

[0.3.31] - 2026-09-23

Added

  • Assertion 27 — a forwarded nil is an absent option. subscribe/2 with to: nil must deliver to the caller: neither to nobody nor by raising. This family forwards opts unchanged, so an option a caller's own caller never set arrives as key: nil, and Keyword.get/3 substitutes its default only for an absent key — the trap DpExchange.Core.Config.opt/3's moduledoc says was closed "as a class". It had not closed everywhere.

    Found on :to in all five venue packages at once, failing in OPPOSITE directions: the real Feed put nil in its subscriber set and Fanout.deliver/4 resolved it to nobody, so subscribe/2 answered :ok and data never arrived; the fakes raised on send(nil, _). A consumer's tier-1 tests could not have shown them the real behaviour even by accident. Break-verified: reverting any of the four fakes that read :to fails it. Deliberately not asserted for :name, where nil is OTP's own "do not register" rather than "unset".

Fixed

  • Fanout.deliver/4 with a forwarded max_queue_len: nil switched back-pressure off. Keyword.get/3 handed nil on as the bound, and queue_len >= nil is always false — an atom sorts above every integer — so no subscriber was ever over its bound, silently, on the path every venue's data flows through. It now uses the stated default. max_queue_len!/2 deliberately still REFUSES nil at init/1, and its test holds it to that: there a refusal is loud and costs a restart; here, per message, the safe direction is the bound.

  • ReferenceVenue.subscribe/2 raised on to: nil — caught by assertion 27 on its first run, the third time a new assertion has found a bug in Core's own worked example.

[0.3.30] - 2026-09-23

Fixed

  • A symbol removed and re-added stacked a second poll timer, permanently multiplying the venue's request rate for it. handle_cast({:update_symbols, _}) scheduled wanted - state.symbols, reasoning that a symbol already in scope already has a timer. That is true for a symbol which stayed, and false for one that LEFT and came back: the removal took it out of state.symbols while its pending timer went on existing, so the re-add counted it as new and armed a second. reschedule/2 then re-arms both, forever.

    Measured, per-symbol mode at a 200ms interval, one symbol churned and one left alone as a control:

    beforeafter 3 remove/re-add cycles
    churned symbol5 fetches/s20 fetches/s (x4)
    control symbol5 fetches/s5 fetches/s

    Every cycle stacks another timer and none of them decay. This is exactly the multiplier the update_symbols comment in this module says it exists to prevent — "stack a second timer on each, doubling this venue's request rate every time the scope is touched" — arriving through the one door the guard did not cover, and it is the defect class the moduledoc names: "an unexamined multiplier on request volume is a defect class this family has paid for more than once."

    State now carries :scheduled, the symbols that already have a tick coming — timer pending, job queued, or fetch in flight — and update_symbols/2 subtracts that rather than state.symbols. reschedule/2 re-arms only a symbol still in scope and drops it from the set otherwise, so a later re-add sees a clear field exactly once.

    Reaches dp_exchange_schwab's fallback poll, which runs per-symbol mode over precisely the symbols the Streamer cannot hold — the set most likely to churn. dp_exchange_robinhood uses :fetch_all and is unaffected. PollingFeed is public Core API, so any consumer in per-symbol mode is in scope too.

    The test beside the new one asks the sibling question — "an existing symbol is not rescheduled a second time" — about a symbol that stayed. Nobody had asked it about one that came back. The new test asserts the churned symbol's fetch count against an untouched control rather than against an absolute number, so it measures the defect and not the speed of the machine; break-verified at 39 against the control's 10, and stable over eight separate runs.

[0.3.29] - 2026-09-15

Added

  • Assertion 26 — subscription set semantics. subscribe/2 adds to the live set, update_symbols/2 replaces it, unsubscribe/2 removes only what it names. The three are different operations on one set, and nothing checked that a venue kept them different.

    Found by asking all five fakes the same four questions in order. Four of five answered that a second subscribe/2 REPLACED the first — in each of them subscribe/2 and update_symbols/2 were byte-for-byte the same line — while every real Feed in the family unions (wanted: MapSet.union(state.wanted, MapSet.new(symbols)); dp_exchange_robinhood spells it current ++ symbols). So a consumer's tier-1 tests certified the opposite of what the venue does: subscribe twice, keep one symbol against the fake and both against the venue.

    DpExchange.Core.Venue.subscribe/2's own doc does not say which it is, and this assertion does not need it to. The contract settles it by shape: DpExchange.Core.Venue.update_symbols/2 exists to "change a live subscription's symbol set" and DpExchange.Core.Venue.unsubscribe/2 to stop delivery for named symbols, and neither has any purpose if subscribe/2 already replaces. Two callbacks the contract distinguishes, implemented as one function, is the tell.

    Break-verified in both directions on all five venues: reverting a fake's subscribe/2 to replace fails it, and making update_symbols/2 additive fails it too. The assertion also refuses a sample_pairs too short to tell "added" from "replaced", for the reason docs/reference/core/assertion-coverage.md records under "Third axis".

    What it does not catch, stated plainly: the real feed's own set arithmetic. The suite drives fakes; each venue's feed tests hold the real one. What it does catch is the fake drifting from it, which is the half that reaches a consumer's test suite.

Fixed

  • Core.ReferenceVenue.update_symbols/2 was :ok and nothing else, leaving the reference implementing two of the contract's three subscription operations. update_symbols/2 REPLACES the live set — that is the whole difference between it and subscribe/2 — and a reference where one of them does nothing teaches the next venue author that the distinction does not matter.

    Caught by assertion 26 on the run that introduced it, the same way assertion 14 caught this module's coverage/1 reporting a constant one release earlier. Both had been sitting there for exactly as long as nothing asked.

[0.3.28] - 2026-09-15

Fixed

  • Assertions 16, 18 and 19 shared a single off switch: a package_root that matches no module. All three read compiled modules under that root, and all three answered {:ok, []} — "no violations" — when it matched nothing. One mistyped or stale package_root: therefore disabled three assertions at once, two of them the credential-leak checks, with nothing to distinguish it from a package that is genuinely clean.

    The measurement, pointing package_root: at a directory that does not exist:

    published 0.3.27this change
    dp_exchange_coinbase48 tests, 0 failures48 tests, 3 failures
    all five venues—3 failures each

    UnwiredCheck.run/3, LinkSafetyCheck.run/2 and CredentialRedactionCheck.run/2 now answer {:error, {:no_modules_scanned, lib_root}}, and the three assertions carry a message naming package_root: as the thing to fix — that error reaching a bare assert {:ok, _} = would otherwise read as a broken check rather than a misconfigured suite. There is no legitimate caller with zero modules to scan.

    Three of Core's own tests asserted the old behaviour — test "an empty beam_dir yields no violations", once per check — and now assert the error. A fourth was itself inert: CredentialRedactionCheck's "a module whose source is outside lib_root is never analysed" compiled the outside module alone, so its refute held over an empty list and would have passed whether the exclusion worked or not. The two sibling checks' versions of that test each already carried an inside module; this one did not. It now compiles both and asserts in both directions.

    Checked and found sound, recorded so the next audit does not repeat it: assertions 19 and 22 each go red on all five venues when a Credentials struct's redacting Inspect is removed; every capability-derived collection the suite iterates is non-empty on every venue; and assertion 13 exercises Core.Config's own process-scoped overrides.

[0.3.27] - 2026-09-15

Fixed

  • Assertions 14 and 15 had never been able to fail, on any venue. Both read the real venue module — @venue.coverage([]) and @venue.coverage_by_kind([]) — in a suite that starts no feed. coverage/1 reports what is actually arriving, so with nothing subscribed it answers %{}; measured on all five packages, %{} every time. Assertion 14's for {_symbol, route} <- ... ran zero times and assertion 15 compared MapSet.new([]) to MapSet.new([]).

    This is a step past the "Third axis" case closed the same day, where one venue's fixture was too thin to express the defect. Here no fixture was reached at all.

    The measurement, breaking exactly what each assertion exists to catch, in dp_exchange_coinbase's fake:

    breakpublished 0.3.26this change
    coverage/1 reports a route that is a claim (:will_stream)48 tests, 0 failures48 tests, 1 failure
    coverage_by_kind/1 names a symbol coverage/1 does not48 tests, 0 failures48 tests, 1 failure

    Both now drive the fake and call subscribe/2 with the venue's own sample_pairs first, which is what every other fake-driven assertion in the suite already did. Each also requires the coverage it reads back to be non-empty — without that the two slide straight back into passing for having looked at nothing, which is how they got here. Reproducing the old inert state on purpose, by removing the subscribe/2 call, now fails 2 tests on every one of the five packages.

    The structural claim the old calls were implicitly making is kept, and is now stated as its own assertion rather than as a loop's silent precondition: with nothing subscribed, coverage/1 must answer %{} and coverage_by_kind/1 must carry no symbols under any kind. A venue reporting coverage for symbols nobody asked for is reporting a claim, which is the thing the callback exists not to do.

    dp_exchange_robinhood was the one partial exception, and it is worth naming because it shows how thin the accidental coverage was: its coverage_by_kind([]) answers %{top_of_book: %{}} even when empty, so assertion 15's "names no kind the venue does not declare streamable" check was live there and inert on the other four.

    What this does not catch, stated plainly: the real feed's own coverage bookkeeping. The suite drives fakes; each venue's feed tests hold the real one. Assertion 25 records the same division of labour for the same reason.

  • Core.ReferenceVenue.coverage/1 reported a claim, not an observation — and the new assertion caught it on its first run. It returned Map.new(@symbols, ...): every symbol the module knows about, reported as covered whether or not anything had ever been subscribed. Its own comment, directly above, said "this reference observes its own sends, so it reports what it actually delivered". It observed nothing. It was a constant.

    That is exactly the substitution DpExchange.Core.Venue.coverage/1 exists to forbid, in the module every venue author reads as the worked example and the fixture Core runs its own conformance suite against. subscribe/2 now records which symbols actually got a send — not which were asked for, so a symbol this reference refuses or does not list is not counted — unsubscribe/2 removes them, and coverage/1 reports that. Process-scoped, because the suite requires async: true safety and asserts process-scoped isolation in its own assertion 13.

    Worth stating as evidence rather than as a coincidence: this bug had sat in Core's own test support for as long as assertion 14 had been inert, and it surfaced the moment that assertion was able to fail.

[0.3.26] - 2026-09-15

No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.

[0.3.25] - 2026-09-15

Fixed

  • Assertion 25 could not fail on a fake whose book was one level deep, and assertion 24 could not fail on an empty balance list. Both ran, both reached the behaviour under test, and neither was able to distinguish a pass from a failure.

    assert_side_ordering/4 compares a side's prices against those same prices sorted. Against a one-element list that is a tautology — a list of one is sorted under every comparator — so for dp_exchange_gemini, whose fake returned exactly one bid and one ask, no edit to that fake could have turned the assertion red. Measured rather than argued, with this repository's own method of breaking the thing on purpose:

    dp_exchange_gemini fakecontract suite
    one level a side, as shipped48 tests, 0 failures — and nothing can change that
    a second, deliberately misordered level48 tests, 1 failure

    The other four were fine by accident of fixture depth rather than by anything checking it: three levels on dp_exchange_coinbase, two on dp_exchange_webull, and an honest :unsupported skip on the two venues that serve no REST book.

    Assertion 24 has the same shape one step along: Enum.each(balances, &assert_balance/1) over {:ok, []} executes zero per-balance assertions and reports green.

    A book thinner than two levels a side, and an empty balance list, are now failures, with messages saying that the fixture is what needs deepening and not the venue.

    This is a third axis on top of the two docs/reference/core/assertion-coverage.md already records. That file asks whether an assertion exists, then whether it runs on every venue. Both were yes here. The question it had not asked is whether an assertion that runs is able to fail, and that one is answered against the fixture rather than against the assertion. Written up there as "Third axis", with the question for the next audit: what would I change to make this fail?

  • DefaultRateLimiter accepted a limit: 0 and then died on the first request. :limit is the divisor in the GCRA arithmetic, so a zero reached div(_, 0) and raised :badarith inside the limiter's GenServer, on the first call — the exact crash loop that validate_limits!/1 exists to prevent, which it had been letting through because its guard read l >= 0. Under a supervisor it restarts and crashes again on the next request; the message names reserve/3, not the configuration that is wrong. And record/3 cannot fail by design, so it kept answering :ok to a limiter that was no longer alive: the bucket that every ceiling is measured against silently stopped being written, which is the family's recorded rate-limit failure — a venue answering 429 while the budget panel reads comfortable.

    Reachable, not hypothetical, and by the obvious route: Capabilities.ceiling declares :limit as non_neg_integer and means it — limit: 0 is the documented way to say the venue serves the endpoint but this application's registration was granted none of it. Four of the five venue packages wire a capabilities/0 ceiling straight into this limiter. Two packages had already hit the division from their own side and floored their own rate at 1 locally, while the shared module that performs the division went on accepting 0.

    start_link/1 now refuses a zero rate with a message that separates the two meanings, so a reader does not "fix" it by deleting the zero from capabilities/0, where it is correct. This is the second way Core.Capabilities.ceiling/0 and DefaultRateLimiter's t:limit/0 fail to compose — :burst optional-vs-required was the first — and both are now named in the error a consumer actually sees.

    The test that covered this asserted the opposite: that limit: 0 starts, on the stated reasoning that a venue package "relies on it being expressible". It relies on it being expressible in capabilities/0; it deliberately does not hand it to the limiter. The assertion stopped one line short of the question that mattered — start_link returning {:ok, pid} was never in doubt — and defended the crash for as long as it stood.

[0.3.24] - 2026-09-14

No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.

[0.3.23] - 2026-09-14

Fixed

  • StakingRate.bps_to_pct/1 crashed on a rate the venue did not state, and accepted one that is not a number. It handed a binary straight to Decimal.new/1, which is wrong in both directions:

    • It raised. Decimal.new("") and Decimal.new("n/a") raise Decimal.Error, so a venue omitting a rate, or naming it in words, took the caller down rather than reporting an absent rate. Decimal.new(" 500 ") raises too — padding is not a number to die on.
    • It accepted "NaN" and "Infinity". Both parse, so both became a Decimal and then a rate. Every venue package already refuses them, with the measurement recorded beside each copy: Decimal.add(nan, 1) is NaN and poisons a consumer's arithmetic silently, Decimal.compare(nan, _) raises in the CONSUMER's process naming Decimal rather than the venue, and an Infinity compares greater than everything and never raises at all.

    It now answers nil for anything it cannot read — the convention every venue's own decimal/1 already follows. The shared helper was less careful than the five copies it exists to replace, which is the opposite of the reason it was written: "provided here rather than left to each venue package, because it is the conversion most likely to be done inconsistently".

    The spec widens to Decimal.t() | nil. Valid input is unchanged. Nobody can have been relying on the raise, and nil in place of a NaN rate is both detectable and correct.

  • CanonicalPair.to_exchange/2 appended the venue's separator to a symbol that had no quote part. "AAPL" came back "AAPL-", and "" came back "-". It built "#{base}#{sep}#{quote}" unconditionally, so anything that is not a BASE-QUOTE pair was decorated into something that looks like one.

    That is the family's signature shape: a plausible string that matches nothing. It goes into a request URL, the venue answers 404, and classify/1 reports {:refused, :not_listed} — this package telling a caller the VENUE said a symbol is not listed, when what happened is that the normaliser invented a symbol the venue was never asked about.

    dp_exchange_coinbase and dp_exchange_robinhood both map with sep: "-" and were exposed. The concat venues (sep: "") never were, because joining with an empty string is a no-op — and that is exactly why the existing test missed it: "a bare asset with no quote survives" used the concat mapping, the one shape where the bug cannot occur.

    Input with no quote part now comes back unchanged, which is what to_canonical/2 already does with input it cannot split. The two directions agree about what they cannot interpret.

[0.3.22] - 2026-09-14

No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.

[0.3.21] - 2026-09-14

Changed

  • The fan-out no longer asks whether a subscriber is alive before asking how deep its mailbox is. deliver/4 called resolve/1 (Process.alive?/1) and then deliver_one/5 called Process.info(pid, :message_queue_len) — and that second call already answers nil for a process that is gone, which deliver_one/5 already treats as "nobody to send to". The liveness check was duplicating an answer the next line was about to get anyway, once per subscriber per message, on the hottest path in the family.

    Measured against deliver/4 itself, median of fifteen runs of 30_000, µs per fan-out:

    |                     | before | after  |
    | 1 live              |  1.057 |  0.655 |
    | 3 live              |  1.385 |  1.149 |
    | 10 live             |  4.578 |  2.967 |
    | 1 live + 1000 dead  | 20.046 | 28.728 |

    The last row is a real regression and is stated rather than buried. Process.info/2 does more work than Process.alive?/1 before concluding a process is gone, so a set full of dead pids costs more now. It is still the right default: a set full of dead pids is not a state to tune for, it is the bug watch/2 and forget/2 exist to prevent, and a correctly pruned set is the first three rows — which is what a running system is in on every message of every day.

    Behaviour is identical either way, and resolve/1 is unchanged and still public: a venue's notice path has no second call to lean on.

    The measured table in Fanout's own moduledoc was taken when this path used Process.alive?/1, so it has been re-measured rather than left to drift, and the derived figure beside it (the cost of 4258 level2 frames against a thousand dead pids) recomputed from the new numbers: 97 ms becomes 119 ms.

[0.3.20] - 2026-09-14

Fixed

  • An out-of-range rate-limit reset raised out of every response. parse_reset/1 used DateTime.from_unix!/1, which RAISES rather than returning an error, and parse_rate_limit_headers/1 runs on every response. A venue moving that header from seconds to milliseconds — ordinary drift — sends a value that is invalid Unix time, and the exception came out of the request path rather than out of anything that looked like a header problem. A rate-limit header this package cannot read must not be able to fail the request it rode in on. nil now, which is the answer the caller already handles.

[0.3.19] - 2026-09-14

No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.

[0.3.18] - 2026-09-14

Fixed

  • A malformed rate-limit header was read as its leading digits. fetch_integer/2 took {integer, _rest} from Integer.parse/1, which matches anything merely STARTING with a number — so "100 requests" became 100 and "37/100" became 37. Those flowed on as facts: into the limit/remaining budget the limiter throttles against, and into parse_reset/1, whose own comment says it answers nil "rather than a guessed instant" — a promise it could not keep while a partial parse produced one. A malformed header is exactly when guessing is worst, because the guess is unverifiable and silently changes how hard this package hits the venue. A whole-string parse is required now.

    The test covering this used "lots", which Integer.parse/1 refuses outright, so it never reached the partial case.

  • VolumeProfile.point_of_control/1 returned the HIGHER price on a tie, where its own doc promises the lower. The tie-break was price_a <= price_b, and those keys are the venue's own price STRINGS — kept that way on purpose, see the moduledoc. Text order is not numeric order wherever the strings differ in digit count: "10.00" <= "9.00" is true, and so is "100.5" <= "99". So every tie spanning a digit-count boundary — a crypto price crossing 9.xx/10.xx, a stock crossing 99/100 — answered with the wrong one of the two.

    Reachable through a real venue: dp_exchange_webull's get_volume_profile/4 passes the venue's raw price strings through as keys.

    Nothing about it was visible. The answer was still one of the tied prices, so it stayed plausible and only its meaning was wrong. The test that covered ties compared "24.20" with "24.21" — equal digit counts, where the two orders agree — so it passed throughout.

    Ties now break on the parsed value, with the string still breaking a tie between two spellings of the same number ("24.2" and "24.20" are two of the venue's own rows and cannot be ordered by value) so the answer stays stable across calls. A key that does not parse whole loses the tie rather than deciding it, and "12abc" is not read as 12.

[0.3.17] - 2026-09-14

Added

  • PollingFeed takes a :provider, so Notice.provider can be trusted for routing. Its notices named state.label, a string. A venue's own Feed builds notices with the venue ATOM, so the same package emitted both — dp_exchange_robinhood sent :robinhood from its feed and "robinhood" from its poll, and dp_exchange_schwab sent :schwab beside "schwab-fallback-poll". A consumer routing notices by provider silently dropped or mis-filed every notice that came from the poll: the value stayed plausible and only its meaning was wrong, which is the substitution this family keeps paying for.

    :provider defaults to :label, so every existing caller answers exactly what it did before and no venue has to change to keep working. A venue that wants provider to mean the venue passes its own atom, and label goes on doing what it is good at — naming WHICH feed inside message and details.label. Schwab's poll is deliberately labelled "schwab-fallback-poll" so a notice pasted into an issue cannot be mistaken for the Streamer going dark; both facts are now sayable at once.

Fixed

  • Notice.provider had no documentation at all. kind and severity each carry a @typedoc; the field a consumer actually routes on carried none, while its type quietly permitted atom() | String.t(). A consumer reading the struct would write notice.provider == :robinhood and never learn why half the notices did not match. It now has a @typedoc saying which values occur, why the string exists, and what a venue package should do about it.

[0.3.16] - 2026-09-14

Added

  • assertions/0 is now checked against the suite it describes. It is public and documented as "for documentation and for a venue package to report against", and no test called it. Nothing connected the published list to the assertion groups that actually run, so a group added without a matching entry — or an entry left behind after one was renamed — would ship silently. Adding group 25 is what surfaced it: the list had to be updated by hand, and nothing would have noticed if it had not been.

    Three tests, read from the describes the suite actually generated rather than by parsing the macro's source:

    • every numbered group that runs is listed — a venue reporting against that list must not claim conformance it was never measured for;
    • the numbering is contiguous from 1, so a gap means a group was dropped;
    • the four listed groups that have no describe of their own are pinned as exactly [5, 6, 9, 10], with why in the test.

    That last one is the part worth knowing. The mapping is deliberately not one-to-one, and asserting the reverse direction would make the list harder to write honestly: 5 (return types) and 9 (fake fidelity) are cross-cutting, with several groups each checking part of them; 6 (error discipline) is checked inside group 12's describe; 10 (facade completeness and exclusivity) inside group 1's. Pinning the set means that if one of the four later grows its own describe, or stops being covered where it is, this test is where the next reader finds out what was intended.

    Checked in Core alone rather than added to the macro: every venue runs the identical groups, so the same comparison in five more repos would report the same thing five more times, and a venue with its own numbered describe in that module would false-positive.

    All three verified by breaking them — dropping group 25 from the list while its describe still runs, and renumbering a group to leave a gap.

[0.3.15] - 2026-09-13

No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.

[0.3.14] - 2026-09-13

No consumer-facing changes. Internal or packaging work only — recorded so every published version has a heading, because an absent one cannot be told apart from one the release pipeline dropped.

[0.3.13] - 2026-09-13

Added

  • Assertion 25 — order book ordering. Core.Types.OrderBook states that bids descending and asks ascending is part of the contract and anticipates the mistake by name, and nothing in this suite checked it. On 2026-09-13 three of the four packages that build an OrderBook were returning the venue's row order; dp_exchange_coinbase was the only one sorting, so the family had both answers running at once and every suite was green. One of the three even carried a test asserting the venue's order was kept, with an ascending bid list as its fixture.

    This is the suite's own policy applied — "Every gap found becomes a new assertion here. A gap fixed only in one venue's fake is a gap the next venue reintroduces."

    The assertion also requires every level's price to be a real Decimal, which was the second half of the same defect: level/0 is {Decimal.t(), Decimal.t()} and one package was carrying {nil, _} levels, so hd(bids) could hand a caller a nil best bid.

    What it does not catch is recorded in the assertion itself: the suite drives the venue's fake, and all five fakes already returned sorted books — the drift was in the real decoders, which this suite never sees. Driving the real one would make every ordinary mix test dial the live API, and feeding it an unsorted fixture would mean handing the suite a plug:, which is transport and which this module's own rule forbids an assertion to name. Each venue's own tests hold its decoder; this holds the fake and the next venue.

[0.3.12] - 2026-09-13

Fixed

  • Trade.new/1 demanded a non-nil :id and :side that two venues cannot supply, and its typespec said they were never nil while a shipped venue returns one. @required_non_nil is now narrower than @enforce_keys — the same split Types.Balance already makes, for the reason it states: state what is true rather than a stricter rule the packages then quietly violate.

    • dp_exchange_webull's tick topic publishes no per-print identifier, on the socket or on its REST tape, and its usage rules say so. Its decoder records that Trade.new/1 "is deliberately not used here: it would raise on the very absence this comment and the moduledoc both document as real rather than accidental".
    • dp_exchange_gemini on :side: "Absent means the venue did not say which side lifted, and neither answer is honest." dp_exchange_webull matches only the venue's documented "B"/"S" and answers nil otherwise.

    The cost was not the rule being wrong — it was the packages' response to it. Bypassing the constructor skips the checks that ARE right, so one unkeepable requirement cost the other four: a nil price, quantity, timestamp or symbol went unchecked in every venue that built the struct literally to avoid raising on the id.

    @enforce_keys still lists both, so a decoder that forgets the key entirely is caught; what is allowed is stating the absence. t:t/0 now reads id: String.t() | nil and side: :buy | :sell | nil, which is what the family already produced.

    Consumers must treat :id and :side as optional. This is not a behaviour change — %Trade{id: nil} has been arriving from dp_exchange_webull all along — it is the typespec catching up, so dialyzer can see it.

    Found by a mechanical audit of every literal struct construction in the family against the fields its Core type requires non-nil.

[0.3.11] - 2026-09-12

Changed

  • usage-rules/adapter.md states the Balance rule the type now enforces. :balance may honestly be nil and :currency may not — two enforced keys that are not the same kind of required, and reading them as if they were is how a package reports an unusable value as success. The contract changed in 0.3.5 and the document a consuming agent reads did not say so, which left the rule discoverable only from new/1 raising.

    It records what each nil means for a caller: an unstated total is a real answer and must be read as unknown rather than zero, with available_balance beside it often still real; an unstated currency is not an answer at all, and a venue package that cannot name the asset must refuse the row. Assertion 24 holds every venue to the second half.

[0.3.10] - 2026-09-12

Fixed

  • Three checker test suites failed whenever two mix test runs of this package overlapped. UnwiredFixture names each fixture directory from System.unique_integer/1, which is unique within a VM and restarts near 1 in the next, so two runs generate the identical beam_2, beam_3, … names. test_helper.exs then made that fatal rather than merely confusing: it wiped tmp/unwired_check_test wholesale before ExUnit.start/0, so a second run's start-up deleted directories the first run's tests were still writing into —

    ** (File.Error) could not write to file ".../tmp/unwired_check_test/beam_2/
       Elixir.Outsider1.beam": no such file or directory

    — in whichever of UnwiredCheckTest, LinkSafetyCheckTest or CredentialRedactionCheckTest happened to be mid-fixture. It read as a rare flake because it needed two runs to overlap; it is a deterministic collision.

    Each run now owns a pid-named subtree (UnwiredFixture.run_root/0) and test_helper.exs wipes only its own, which keeps the stale-beam guarantee that wipe exists for and cannot reach another run's files. run_root/0 is a function, not a module attribute: an attribute is evaluated at COMPILE time and would bake in the compiling VM's pid, handing every later run the same directory — the exact collision being removed.

  • Two PollingFeed tests waited exactly one poll interval for a synchronisation message. Both set interval_ms: 500 and then assert_receive ..., 500, so the wait for a message the first poll produces had zero margin for scheduler jitter — despite a comment claiming the wait made the ordering deterministic. They held on a quiet machine and failed under load with "Found message matching :fetch_started after 500ms": the message arrived, just late.

    Raised to 5s. Waiting longer cannot weaken either claim — that status/1 and coverage/1 answer while a fetch hangs — because how long the hang takes to begin is scheduling, not behaviour. Pinning it to the poll interval was measuring the harness.

    Both were found by running two suites against each other, which is now the recorded way to surface this class in this package: a timing assertion that holds when quiet and fails under load is the same shape as the rate-limiter bucket race fixed in 0.3.6.

Changed

  • Every fake call in the conformance suite now builds its arguments through endpoint_args/2. Assertions 12 and 21 still hand-built theirs. Both happened to carry credentials:, so neither was among the assertions found inert in 0.3.9 — but hand-building is precisely what made 14 and 23 inert on four venues out of five, and a mechanism that some call sites use and others do not is one drift away from the same defect. A venue's endpoint_opts and endpoint_symbols now reach every fake call the suite makes.

    Assertion 21 still chooses its own timeframe, because an unserved width is the whole question it asks; its symbol and opts come from the venue's declarations like everything else.

  • docs/reference/core/assertion-coverage.md records a completed mutation sweep. All seven fake-driven assertions were broken on purpose — the exact property each one claims — against every venue the assertion applies to, and every break produced a failure. Nothing was found still inert, which is the result worth writing down: it is what stops the next reader repeating thirty experiments to learn it.

    The table records which venues each assertion applies to and why the counts differ, since assertion 23's uneven 3/3/3/2/1 matches each venue's own capabilities/0 exactly — that is what "runs where it should and nowhere else" looks like when it is visible. Two legitimate skips remain, both declared rather than inferred from an opaque refusal.

[0.3.9] - 2026-09-12

Fixed

  • Assertions 14 and 23 had only ever run on one venue out of five. Both build a call to the venue's fake for a PUBLIC-shaped endpoint — get_top_of_book/2, get_price/2, get_order_book/2 — whose argument shape carries no credential position, so the only place a credential can travel is opts. Both passed a hand-built [symbol, []]. Every venue declaring credential_benefit: :required therefore answered {:error, {:missing_credentials, _}}, and each assertion's own _refused_or_unsupported -> :ok clause took that for an answer. Only dp_exchange_gemini, which needs no credential, ever executed them.

    Measured, not inferred. Setting a fake's observed_at to nil — the precise defect assertion 14 exists to catch — and running dp_exchange_schwab's contract suite against the published Core 0.3.8 reports 0 failures. Against this change, all five fail, with counts matching each venue's own capability declaration exactly: 3 for the venues serving all three endpoints, 2 for dp_exchange_schwab (no order book), 1 for dp_exchange_robinhood (no get_price/2, no order book).

    Both now build their arguments through endpoint_args/2, and arg_value(:opts, _) carries credentials: via Keyword.put_new/3 — so a venue's own endpoint_opts still wins, and assertion 17's deliberately-emptied credential is not fought.

  • The same _refused_or_unsupported -> :ok clause closed in assertion 24 was left open in 14 and 23. Fixed in one place and not the others, which is the "fix applied where it was found rather than where it applies" this family keeps paying for — committed here, in the suite whose job is to stop exactly that. A refusal from an endpoint capabilities/0 declares active now fails in all three, naming both honest remedies.

Added

  • endpoint_symbols: on use DpExchange.Core.AdapterContract, a %{{name, arity} => symbol} for the endpoint whose coverage is narrower than sample_pairs:. dp_exchange_webull serves an order book for US stocks and ETFs and refuses one for a crypto pair — deliberately, since the venue publishes no crypto depth endpoint — and all of its sample pairs are crypto, so assertion 23's get_order_book/2 arm could only ever see that refusal.

    The alternative was to keep treating the refusal as a skip, which is how these assertions came to run on one venue in five. Naming a symbol the endpoint actually serves makes the assertion run instead, which is the point of having it.

[0.3.8] - 2026-09-12

Fixed

  • Assertion 24 accepted a refusal as a pass. Its helper ended _refused_or_unsupported -> :ok, and that one clause is how three packages passed an assertion that never executed on them: their fakes refused an account-scoped call the suite made with no account, and the refusal was taken for an answer. endpoint_opts (0.3.7) removed the reason for that refusal, so a refusal now means something is genuinely out of step — capabilities/0 says the endpoint is active and the venue's own fake says otherwise — and it fails, naming both honest remedies: declare the endpoint :unsupported, or declare what the fake needs in endpoint_opts:.

    Verified by renaming a venue's endpoint_opts: key so Core stops seeing it, which reproduces the pre-0.3.7 state exactly: the suite now fails there instead of passing.

Changed

  • docs/reference/core/assertion-coverage.md brought current, and given a second axis. It was the map the next audit starts from, and it had drifted into pointing at three problems that no longer exist while missing the newest assertion: it said 23 groups (24 now), and it still reported as open both the dp_exchange_coinbase has_staking finding and the "tier 1 dials out" finding on assertions 12 and 14. Both are fixed — re-verified in source and by running all five contract suites, which reach no venue hostname at all — and are now marked closed in place rather than edited away, since the tables are a record of what the audit found on the day.

    The new part is the axis the original audit did not have. Every status in that file answers "does an assertion exist?", which is not the same question as "does it run on every venue?" — and the difference had been hiding real absence behind green runs. The file now opens by saying so, and records the method that found it: break what an assertion checks and require the suite to go red, on every venue rather than on the first. Two of the five would have told you nothing.

[0.3.7] - 2026-09-12

Added

  • Assertion 24: a Balance names the asset it is a balance of. Types.Balance enforces :currency and its new/1 refuses a nil there, but no venue decoder in this family calls new/1 — every one builds the struct literally, which Types.Validate's own moduledoc explicitly permits — so that check had never run anywhere, and four of the five packages read currency straight out of the venue's JSON by key with nothing between. All four were fixed in their own packages; this is the assertion that stops the fifth, or a sixth, reintroducing it. The suite's own rule is why it belongs here: "Every gap found becomes a new assertion here. A gap fixed only in one venue's fake is a gap the next venue will reintroduce."

    :balance is deliberately not checked — Types.Balance states it may honestly be nil while :currency may not, and an assertion covering both would force dp_exchange_coinbase to discard a real available_balance to report an absence honestly.

Fixed

  • Three venue packages were passing fake-driven assertions that never ran. Several assertions call an active endpoint through the venue's fake with opts: [], and on an account-scoped venue every one of those calls is refused before it reaches the behaviour under test: dp_exchange_webull needs an :account_id, dp_exchange_robinhood an :account_number, dp_exchange_schwab an :account_hash. The assertions took that refusal as a legitimate answer and skipped — a green test proving nothing, which is worse than a red one.

    Caught by deliberately breaking each venue's fake against the new assertion 24: two packages went red and three stayed green. It was never confined to 24. Assertion 17 — the credential gate — has been passing for the wrong reason on those same three venues since it was written: it strips the credential and expects a failure, and what it got was the missing account.

    use DpExchange.Core.AdapterContract now takes endpoint_opts:, a %{{name, arity} => keyword()} of what a venue's own endpoints need before its fake will answer. The key is the venue's, not Core's — a table of :account_id / :account_number / :account_hash in here would be exactly the venue-specific knowledge this contract exists to keep out of Core. Assertion 17's stripped-credential args carry those same opts alongside the emptied credential, so the only thing missing from the call is the one thing it is testing.

[0.3.6] - 2026-09-11

Fixed

  • Types.Balance required a non-nil :balance that every package in the family knowingly produces. Its new/1 checked the full @enforce_keys, and its typespec said balance: Decimal.t(). dp_exchange_coinbase derives the total from the venue's available and hold figures and carries nil when either is missing — deliberately, on the recorded ground that "available 1, total unknown" and "total equals available" are different claims — and dp_exchange_gemini has its own recorded decision to carry nil rather than destroy a page over one malformed row. The type denied both.

    Nothing caught the contradiction because no venue decoder calls new/1: all five build the struct literally, 85 call sites between them, so the constructor's check has never run in this family. A rule nothing enforces and every package violates is not a rule, it is a wrong statement in the contract.

    :balance is now Decimal.t() | nil and out of the non-nil check, with the moduledoc saying when a nil is honest. @enforce_keys still guards its presence, so a decoder that never sets the field is still caught — stating an absence and omitting one are different mistakes.

    :currency stays required, and now means it. A balance attributable to no asset cannot be sized, booked or reconciled by anyone, and unlike a missing quantity there is no "the venue declined to say" reading — a holdings row names its asset, so a nil there is a decode reading the wrong key, exactly what Types.Validate's moduledoc exists for. The four venue packages that read this field from venue JSON now refuse such a row; see their own changelogs.

    Same shape of decision, and the same reason, as Types.Order's already-narrowed @required_non_nil: state what is true rather than a stricter rule the packages then quietly violate.

  • Three rate-limiter tests raced the bucket they were measuring. Each ended by asserting the bucket was empty, against a bucket that refills continuously: at per_ms: 1_000 with limit: 3 a token returns every ~333ms, so the closing check/3 was only refused if the acquires ahead of it finished inside that window. They normally did. On a loaded async: true run they sometimes did not — caught failing once in six consecutive full-suite runs, passing the other five.

    The same flake dp_exchange_coinbase's limiter test carried and was fixed for: any "the bucket is empty now" assertion races a refilling bucket, and no change to the code under test removes it, because the race is in the assertion. Widened to per_ms: 100_000, which puts every refill interval here past nine seconds while keeping the non-terminating division (100000/3 is 33333.333…) that each test actually exists to pin. Verified with six consecutive clean full-suite runs.

[0.3.5] - 2026-09-11

Fixed

  • A flaky test of my own making, from 0.3.4: the dead-subscriber benchmark asserted timed(unpruned) > timed(pruned). That is a wall-clock comparison between two small numbers, and it holds while the machine is quiet and inverts under load — it passed in isolation every single time and failed inside the full suite under --cover, where twenty async tests are competing for the same cores.

    This is precisely what this suite's own wait_until/1 comment says about sleeps: a test that fails against code which is working correctly is worse than no test, because it teaches the reader to distrust the suite. Writing one two releases after quoting that comment at someone else's test is worth recording rather than quietly rewriting.

    The property is real — deliver/4 walks the whole set and calls Process.alive?/1 per entry per message, measured at 0.095 µs per fan-out against a clean set and 22.8 µs against one carrying a thousand dead pids. But the magnitude is a measurement, and it belongs in a comment and a changelog, where it already was. What belongs in an assertion is the mechanism, which is exact: the work per message is one liveness check per entry, so the cost is the size of the set. The test now counts that — 41 entries walked before pruning, 1 after, same delivery result — instead of timing it.

    Found by clean-building every repo in the family, which is a habit that came out of dp_exchange_webull 0.4.14: an incremental build is not a quieter build, it is one that has already told you and moved on. No hidden warnings turned up anywhere; this failure did.

    The two other :timer.tc assertions in the family were examined and left alone. They are a different shape: bimodal checks with wide margins — "is this instant, or did it sleep the configured minute" at 60× margin in this package, 10× in dp_exchange_schwab — rather than a comparison between two similar small durations. Changing them would be motion with no defect behind it.

[0.3.4] - 2026-09-11

Fixed

  • A malformed :limits crashed the rate limiter on its first request instead of failing at start. reserve/3 destructures %{limit: _, per_ms: _, burst: _}, so an entry missing any of the three raised a MatchError inside the GenServer. A supervisor then restarts it, the next request crashes it again, and what a consumer sees is a crash loop whose message names reserve/3 rather than the configuration that is actually wrong.

    This is a real trap, not a hypothetical one, because two of Core's own types do not compose. Capabilities.ceiling declares :burst optional; this module's t:limit/0 declares it required. So every venue in the family has to write its own little to_limit/1 to bridge them — all five happen to do it correctly — and a consumer wiring DefaultRateLimiter up directly against a capabilities/0 ceiling, which is the obvious thing to do and the shape that reads as correct, got the crash loop instead of an error.

    init/1 now validates every entry and raises with a message naming the provider, the required shape, and why the ceiling it was probably given lacks :burst.

    Raising rather than defaulting burst to limit is deliberate, even though that is what @default_limit itself does and what four of the five venues chose. Burst tolerance is how far a caller may run ahead of the smooth rate; picking one silently hands a consumer a throughput characteristic it did not choose and cannot see — the same objection Fanout.max_queue_len!/2 already records for a back-pressure bound. A ceiling is a claim about a venue, and this module is not the place to invent half of one.

    A limit: of 0 stays legal and is tested: it is a real declaration — a registration that granted no throughput, which dp_exchange_schwab relies on being able to express — and must not be confused with a missing value.

    Found while probing the limiter by hand to settle an unrelated flaky test, with %{limit: 10, per_ms: 1_000} — the exact shape capabilities/0 returns.

[0.3.3] - 2026-09-11

Fixed

  • A dead subscriber's pid was never removed from a feed's subscriber set, and the hot path paid for it linearly. Fanout.resolve/1 skips a dead subscriber at send time, so no events accumulated for it — which is what Core.Venue's unsubscribe/2 doc asks for, and it was true. What accumulated was the pid. No venue in the family monitored a subscriber or pruned one, so a supervised consumer that restarts left its old pid behind on every restart, for the life of the feed.

    That is not a rounding error, because deliver/4 walks the whole set and calls Process.alive?/1 on every entry, once per message. Measured:

    dead pids in setµs per fan-out
    00.095
    500.956
    2004.301
    100022.842

    Linear, and at a thousand accumulated pids each message costs roughly 240× what it should. dp_exchange_coinbase's level2 channel measured 4258 frames in the window that produced this family's coverage incident; at that size the dead entries alone are about 97 ms of liveness checks inside the one process every subscriber's data flows through — and it only ever grows.

    Fanout.watch/2 monitors a subscriber so a feed can drop it on :DOWN, and Fanout.forget/2 cleans up on both :DOWN and an explicit unsubscribe. The venue side ships in each venue's own release against this version.

    A registered name is deliberately not monitored. A pid that has died is gone permanently, so removing it is always right. A name is not a process: subscribe/2 accepts one precisely so a consumer can restart under it, and a monitor on a name fires when the current holder dies. Pruning on that would silently unsubscribe a consumer whose supervisor is about to bring it straight back — data loss with nothing to notice it by, which is worse than the leak. A name cannot leak anyway: the set holds one atom however many times the process behind it restarts.

[0.3.2] - 2026-09-11

Fixed

  • A compile warning in a test file never failed a build, and five were sitting in this package's own output. CI ran mix compile --warnings-as-errors, which compiles lib/ only — test files are compiled by mix test, which had no such flag. So every warning from a test file was permanent and green.

    Found by running script/check_dependency_floor.sh by hand and reading what scrolled past. That checker had never executed once: it is scheduled weekly for Monday and landed on a Tuesday, so no cron had come around, and the token available here cannot workflow_dispatch. A checker nobody has ever seen run is a checker nobody has proved works — running it found a different defect than the one it was written for, which is argument enough for running it.

    Two of the five were real defects in the shared conformance suite:

    venue_does_not_serve/0 and coverage_by_kind/1 are optional callbacks, guarded by function_exported?/3 and then called directly — so every venue package that does not implement one got a compile warning out of a macro it did not write. The venue_does_not_serve/0 site had a mitigation (venue = @venue, then calling through the variable) and a comment claiming it worked. It stopped working and the comment did not: Elixir 1.18 tracks the binding through, so the warning had come back and the comment still said it could not. coverage_by_kind/1 had no mitigation at all. Both are now apply/3 with a scoped credo:disable-for-next-line, which is the honest expression of "this module is not knowable at compile time" rather than a trick that happens to suppress a message.

    The other three were the compiler specialising a generic suite on whichever fake happens to be compiling. Core.ReferenceVenue's fake always returns {:ok, _} from get_symbols/1 and always sets venue_time, so the {:error, {:query_required, _}} branch was reported as "this clause will never match" and is_nil(top.venue_time) as a "comparison between distinct types" — for branches that are live in the packages that need them (dp_exchange_schwab genuinely answers {:query_required, _}, and a venue that publishes no BBO time is the case Types.Quote's 0.2.0 split exists for). Left alone, every future venue with a simple fake would inherit mystery warnings from a shared macro.

Changed

  • CI runs mix test --cover --warnings-as-errors. Together with the existing mix compile --warnings-as-errors, no warning now survives anywhere in the build — lib/ and test files both.

    The argument is not tidiness. A handful of permanent warnings is exactly the noise a genuinely wrong one hides behind, and in this family a "clause will never match" in the conformance suite could be an assertion that cannot fail. Verified by injecting an unused function into a test file and confirming the run aborts, then removing it — a gate nobody has watched fail is a gate nobody has proved, which is the same mistake as the checker that had never run.

[0.3.1] - 2026-09-11

Removed — BREAKING

  • DpExchange.Core.DataProvider is deleted. It was a second, competing definition of the venue interface — 24 callbacks, zero implementers — and every shape in it is one this family has since fixed. Its own moduledoc called it "a unified interface for interacting with different trading venues", so a venue author who found it first would have built, plausibly and entirely wrongly:

    Core.DataProvider saidThe real contract saysWhy the difference matters
    decimal_string :: String.t()Decimal in every Core.Types.*string arithmetic, silently
    provider: String.t()provider: atom()every match on :coinbase fails
    balance_data with no timestampTypes.Balance @enforce_keys it"no way to tell a current balance from a stale one" — its own moduledoc
    price_data with one timestampvenue_time + observed_at since 0.2.0the split issue #31 asked for
    {:error, String.t()} everywhere{:refused, term()} vs {:error, term()}the permanent/transient distinction a caller acts on
    get_order_book → {:ok, map()}{:ok, Types.OrderBook.t()}a bare map cannot enforce anything

    Nothing referenced it. Core.Venue and Core.AdapterContract never mentioned it, and no venue package named it outside one stale comment. It shipped in the Hex tarball anyway, which is the whole problem: a contract a consumer can read is a contract a consumer can believe.

    How it survived: it was ported wholesale from the host application on 2026-08-27 (docs/design/closed/2026-08-26_exchange-adapter-package-family.md, item 1.5 — "776 lines across the three"), alongside the contract that replaced it, and nothing ever removed the one that lost. And test/dp_exchange/core/behaviours_test.exs asserted it declared exactly 24 callbacks — a green test pinning a dead contract, which is Core.UnwiredCheck's own line ("a test is not a caller") applied to a behaviour instead of a function.

  • DpExchange.Core.FeedBehaviour is deleted too — a fourth restatement of the contract, with signatures that matched nothing. start_feed/2 existed in no venue. Its update_symbols/2 was wrong for dp_exchange_webull, which takes credentials per call like every other endpoint in this family and so needs update_symbols/3. A behaviour whose shape contradicts all five implementations is not a hook waiting to be used; adopting it would have meant changing five working venues to match a module nobody had ever run.

    Its moduledoc was the valuable part and it is kept, carried into Core.Venue's route() typedoc where the real coverage/1 lives: the poll set guessing which pairs a subscription covered, Webull's "3 messages per second per connection" rationed by a module that could not see it (151 pairs delivering nothing, reading as a quiet market), and a venue with no socket at all described to the user in socket terms. That last one is why :stream names a push rather than a socket, and why :internal_poll is a first-class answer instead of an absence.

    It also removes a duplicate definition of route() — the same three atoms declared in two modules, free to drift.

Added

  • A behaviour-adopters ledger, so the next orphan is visible the day it is written. behaviours_test.exs now enumerates every module in this package that declares a @callback — read from the compiled beams, because @callback inside a quote (which Core.AdapterContract uses heavily) is not a behaviour declaration and a grep cannot tell them apart — and asserts each has an entry naming who implements it.

    An entry of :none is allowed and is not a loophole: it is a visible, reviewable claim that a contract exists with nobody on the other end, which is exactly the state two modules sat in undetected. A new behaviour with no entry fails the build.

    This is the third instance in as many releases of declared in Core, implemented by nobody — after subscribe/2's back-pressure paragraph (0.2.6) and the entire telemetry spec (0.2.7). The pattern is now checked rather than re-found.

Changed

  • This release is 0.3.x, not 0.2.x. Removing a public module is breaking even when nothing implements it, and a minor bump is how this family says so. Venue packages pinned ~> 0.2.8 will not resolve it until their floors are raised — which is the pin doing its job, not a problem to route around.

[0.2.8] - 2026-09-11

Added

  • The polling route reports a link too, so a polling venue is not permanently "down" on a fleet dashboard. Core.PollingFeed now emits [:dp_exchange, :link, :event] per delivered payload, and :link, :up / :link, :down mapped onto the notice_state latch it already had — so the telemetry inherits that latch's once-per-crossing property and cannot storm on a long outage. :link, :down is the feed crossing into delivering-nothing, which is the closest true statement a poller can make about a route it does not hold open.

    Without this, dp_exchange_robinhood — which only polls — and dp_exchange_schwab's fallback poll would emit no link events at all, and a dashboard reading [:dp_exchange, :link, …] across the family would show them disconnected forever. See Core.Telemetry's "Why the category is :link and not :ws": what carries the route is package-internal, and a consumer should not have to know which venues hold a socket.

  • Telemetry.link_event/2, for a route that cannot measure wire size. A poll has no frame — PollingFeed receives a decoded body and hands on a Core.Types.* struct — so there is no point at which a byte count means what link_event/3's does on a socket. :bytes is therefore absent, not zero. Absent and zero are different claims and only one of them is true here: a consumer summing :bytes across a mixed fleet gets the streaming venues' throughput correctly, instead of a total silently depressed by every polling venue reporting a confident zero.

[0.2.7] - 2026-09-11

Fixed

  • The telemetry spec had nine documented event names and nothing in the family emitted a single one. Core.Telemetry's first line said these are the events "every venue package emits". There was not one :telemetry.execute/3 call anywhere — not in Core, not in any of the five venues — for the whole time the spec existed.

    That is worse than an error would have been. :telemetry.attach/4 against a name nobody emits succeeds, so a consumer wired a dashboard to [:dp_exchange, :request, :stop], got no error, and saw an empty panel — which reads as a venue with no traffic, not as a spec nothing implements. A metric that stays plausible while only its meaning is wrong, in the layer least likely to be questioned.

    Found by reading the contract and grepping for anything that honoured it — the same way, and in the same week, as the identical hole in Core.Venue.subscribe/2's back-pressure paragraph. Two for two on things Core declared and nobody built: a guarantee written in a doc and nowhere else is not a guarantee, it is a plan.

    Core.HttpClient now emits [:dp_exchange, :request, :start | :stop | :exception] from make_http_request/5 — the one place a request actually leaves the process, so a new endpoint cannot forget it — and Core.DefaultRateLimiter emits [:dp_exchange, :rate_limit, :hit | :acquire]. Both are shared by all five venues, so this covers every REST call and every metered request in the family. The [:dp_exchange, :link, …] events belong to a venue's own socket and ship in each venue's next release, against this version.

Added

  • Core.Telemetry gained the emitters themselves, rather than leaving five packages to name events by hand. Five chances to write :link_up instead of [:dp_exchange, :link, :up], and the drift would be invisible: the wrong name emits successfully and never reaches a handler. It also keeps :telemetry a dependency of Core alone — a venue calling :telemetry.execute/3 directly would be using a transitive dependency it never declared.

    Telemetry.endpoint/1 strips a URL's query string for :endpoint metadata. A security decision, not tidiness: telemetry metadata reaches logs, aggregators and third-party exporters, and the query string is the one part of a URL that can carry a token. No venue in this family signs in the query today, but endpoint is emitted on every request from every venue present and future, and "none of them do that yet" is not a property a consumer's log retention should depend on.

Changed

  • make_http_request/5 carries the HTTP status out alongside the result. Every error branch had already turned it into prose — {:error, "Server error (503): …"} keeps the status only inside a message string — so :stop would have reported nil for a 503. Recovering it by parsing that string back out would be the string-matching the same function's own 4xx comment objects to, one layer further along. Carried explicitly instead, so a 503 reports 503 and a connection refusal reports nil: without that a dashboard cannot tell a venue that is erroring from a venue that is unreachable, which are different outages needing different responses.

  • A rate-limit hit is emitted from check/3, not only acquire/3. Core.HttpClient uses check/3 unless rate_limit_blocking: true, and that option defaults to false. Emitting only from acquire/3 would have meant the default configuration of the whole family reported no rate-limit hits at all — a metric reading zero because nothing counts, which looks exactly like a metric reading zero because nothing is being throttled.

    A venue's own 429 and this limiter's own wait both emit [:dp_exchange, :rate_limit, :hit], deliberately. A consumer's first question is "am I being throttled"; answering it from two different event names would mean every dashboard has to know both or be quietly wrong. retry_after_ms is always milliseconds, including where the venue's header is in seconds — a panel summing a mixture of the two is wrong by a factor of a thousand without ever looking wrong.

[0.2.6] - 2026-09-10

Added

  • DpExchange.Core.Fanout — back-pressure, which the contract had promised since it was written and no package provided. Core.Venue's subscribe/2 doc said "a venue pushing faster than its subscriber consumes drops oldest beyond a stated bound and emits a :degraded notice saying so". All five venues fanned out with a bare send/2, in five identical private fan_out/2 functions, none of which had ever looked at a subscriber's mailbox. A consumer reading the contract was told back-pressure was handled and declared; neither half was true.

    The exposure is not theoretical. dp_exchange_coinbase's level2 channel measured 4258 delta frames in the window that produced this family's coverage incident. A subscriber that stalls for thirty seconds against a stream like that accumulates a mailbox in the hundred-thousands and the node dies — with no notice, no log line, and coverage/1 reporting perfect health throughout, because the feed was delivering.

    deliver/4 checks each subscriber's :message_queue_len and declines to add to a queue already at the bound (default 10_000, per venue via :max_queue_len). It reports only transitions — :dropping the first time a subscriber is found over, :resumed the first time it is found back under — because a notice per dropped message would arrive at the rate of the stream the consumer already cannot keep up with. The pair is what lets a consumer bracket exactly the window it must reconcile from a pull endpoint.

    Notices are deliberately not subject to the bound: the notice announcing that a subscriber is being dropped must not be the first casualty of that same subscriber being dropped.

Changed

  • coverage/1's neighbouring back-pressure paragraph said "drops oldest", which is not implementable and is part of why nothing implemented it. A sender cannot remove a message from another process's mailbox — the receiver owns its queue. What a sender can do is decline to add to a queue already past its bound, and that is also the better trade for this data: a quote arriving while a consumer is thirty thousand messages behind is worthless by the time it would be read, and the frames it would push out are no fresher. The contract now states the achievable guarantee, and records that an unimplementable sentence does not stay a wording problem — it becomes the reason a real guarantee is missing.

  • The bound is checked on every message to every subscriber, because checking is cheaper than the send it guards. Measured before it was written: Process.info/2 for :message_queue_len costs 0.029 µs against send/2's 0.097 µs, and is unchanged at 0.028 µs against a 100_000-message backlog — it reads a counter the process already maintains rather than walking the queue. At 0.3x the cost of the send, sampling the check or latching it for N messages would have been complexity bought for nothing.

  • resolve/1 is shared. All five venues had written the same pid-or-registered-name resolution privately. It moves here because deliver/4 and a venue's notice path have to agree on what counts as a reachable subscriber.

[0.2.5] - 2026-09-10

Documentation

  • coverage/1 now says what it means across a transport reconnect, because all four streaming venues had it wrong in the same way at once. Every streaming socket in the family returns {:reconnect, state} from handle_disconnect/2, so the socket process survives a transport drop — no EXIT fires, and every delivery-record reset path in every venue was keyed on a process death. Between a drop and a successful resubscribe, coverage/1 answered :stream for symbols arriving from nowhere; where the reconnect restored the socket but the venue silently failed to restore some symbols — the 325-subscribed/174-delivering shape this callback was written for — those symbols answered :stream indefinitely, on frames observed before the disconnect.

    The @doc now states that observation is scoped to the current transport session: on :link_down a venue narrows coverage by the symbols that link carried, the same way it already narrows on the socket's process death and on unsubscribe/2. A consumer sees a brief, truthful dip bracketed by the :link_down/:link_up pair that exists for it.

    It also separates the two routes, which had silently diverged: Core.PollingFeed applies a staleness window and is right to, because a poll that missed its own interval is genuinely not delivering; a stream deliberately does not, because an illiquid pair may honestly not print for hours and on dp_exchange_schwab overnight silence is correct rather than a fault. On a stream, coverage means observed at least once since this connection came up — never "recently".

    Documentation only in this package; the venue-side behaviour ships in each venue's own release. Design: docs/design/2026-09-10_coverage-across-a-reconnect.md, which also records why this rule cannot be carried by a Core.AdapterContract assertion — the suite is fake-driven, a fake has no socket to drop, and driving a venue's real tree is the dead end assertion 18 already documented.

[0.2.4] - 2026-09-10

Documentation

  • streamable, authenticated_streamable and historical_timeframes had no stated meaning for a venue serving more than one asset class. They are flat lists with no class dimension, and two opposite readings were available — "every path serves this" or "some path serves this". Nothing in the contract said which, so a venue author had to guess.

    The rule is now written down: a value belongs in the list if the venue serves it on any path this package reaches — a union, not an intersection. Both multi-asset venues had already chosen that reading independently, which is exactly how the ambiguity stayed invisible: they agreed by coincidence, not because the contract said so.

    What makes a union honest is the second half of the rule, and it is stated with it: the per-call path must fail closed for a combination it does not serve. Declare 1w because your equity bars serve it and then return a 1d bar when a caller asks for 1w on crypto, and you have built the substitution this family exists to stop. dp_exchange_webull is cited as the worked example — it declares 1w/1M for the equity, option and futures bars and answers {:error, {:unsupported_timeframe, _}} for a crypto category.

    The limitation is recorded rather than implied: a consumer cannot ask "which widths for crypto" or "is the book streamable for options", and gets the venue-wide answer plus an honest refusal. Two confirmed instances — dp_exchange_webull's 1w/1M/1y, and dp_exchange_schwab's inability to declare :order_book for options alone, which is one of the two reasons OPTIONS_BOOK stays unwired. Closing it means an asset-class dimension on a published type for a gap no consumer has reported hitting, so the instances are recorded for whoever weighs that next rather than the change being made speculatively.

    Also in usage-rules/adapter.md, since a venue author reads that before writing a declaration.

[0.2.3] - 2026-09-10

Fixed

  • No published version was attributable to a changelog entry (dp-exchange-core issue #32). Every entry in this repository's CHANGELOG.md sat under ## [Unreleased] — in the published tarball, since CHANGELOG.md ships inside it — so a consumer could not tell which version introduced a breaking change, or whether they had already taken one.

    That mapping is load-bearing here rather than cosmetic. This family signals a breaking change with a minor bump, and those changes are repeatedly a refusal tuple or struct gaining a field: invisible to the compiler, and invisible to a test that pins the old shape. The reporting consumer's written upgrade procedure is "read CHANGELOG.md for a ### Changed — BREAKING section, then grep for every clause matching the old shape" — which needs version → change. Without it, ### Changed — BREAKING says that the shape changed and never whether they already have it.

    They gave two incidents from the same three days, and the difference between them is the whole argument: dp_exchange_gemini 0.1.42's refusal-shape change was found after shipping, by reading a fix comment, while dp_exchange_webull 0.4.0's was caught before — because that entry happened to name the version in its prose.

    Two halves, because fixing only one would have let it recur immediately:

    • Going forward, the release pipeline cuts a ## [x.y.z] - YYYY-MM-DD heading itself, in the publish job and before mix hex.publish — a heading added after the upload would describe a tarball nobody can read.
    • Retroactively, the accumulated block now sits under a ## [<version>] and earlier heading. Attributing each of ~1,600 lines to the exact release that carried it is archaeology; this restores the one fact a consumer needs from it — that none of it is pending — which is what the reporter suggested.

    The issue measured five packages, from their deps/. dp_exchange_schwab has the same defect and is not one of their dependencies, so it could not appear in their table: six instances, all fixed here.

[0.2.2] and earlier - 2026-09-10

Everything below this line is published. Entries were accumulated under [Unreleased] from the first release to 0.2.2, so no reader could tell shipped work from pending — dp-exchange-core issue #32. Attributing each entry to the exact version that carried it would be archaeology across hundreds of releases; this heading restores the one fact a consumer actually needs from it, which is that none of it is pending.

Releases from here on cut their own ## [x.y.z] heading at publish time, so this is the last block that will ever need a range.

Added

  • Assertion 23 — venue time and observed time. Quote and OrderBook gained :venue_time and :observed_at in 0.2.0, and nothing checked that :venue_time stays honest. Assertion 14 already made exactly this check for TopOfBook, which carried both fields from the start; 23 extends it to the two types that just gained them, for get_price/2 and get_order_book/2.

    This is a gap the 0.2.0 change created, found by auditing it rather than by it failing.

    It is deliberately not the "comprehensive endpoint → expected-struct map" that docs/reference/core/assertion-coverage.md considered and declined: two named callbacks whose return type the contract already fixes, with no hand-maintained list to rot. Both gate on Capabilities.active?/2, so a venue declaring either :unsupported is skipped rather than failed — dp_exchange_robinhood declares both, and passes by exemption.

    What it catches: a decode bug with a plausible shape — a raw epoch integer, a NaiveDateTime, or a venue string left unparsed in :venue_time.

    What it cannot catch, and the coverage map now says so: a venue putting its own local clock in :venue_time. No assertion can — a DateTime from DateTime.utc_now/0 is indistinguishable from one the venue sent. That is held by the type's documentation and by review, and implying otherwise would make the coverage map worse than useless.

    A third test is structural: neither type may regrow a :timestamp field. Same reasoning as TopOfBook has no price field — a field with no defined meaning gets filled from whichever value is nearest to hand, which is the ambiguity the split removed.

Removed — BREAKING

  • Core.Types.Quote and Core.Types.OrderBook no longer have :timestamp. It is replaced by :venue_time (the venue's own, nil where the venue publishes none) and :observed_at (when this package read it, always present) — the shape Core.Types.TopOfBook has had from the start.

    Why it had to break. :timestamp was documented as "the venue's own… never invented", and two packages could not keep that promise, because the frames they decode carry no venue time at all: dp_exchange_schwab's LEVELONE_* quotes, and dp_exchange_gemini's partial-depth books (the vendor's own AsyncAPI requires only [lastUpdateId, bids, asks] there, where BookTicker requires E). With one field their only options were to lie or to drop real data, and they lied — a read time in a field a consumer was told was the venue's.

    What it costs a consumer, and what it buys them. Every call site reading .timestamp on these two types changes. In exchange they can express a policy they previously could not: store :venue_time as the point time where the venue dated the frame, and where it did not, store :observed_at and record that you did — so a mis-bucketed value is attributable rather than invisible.

    That framing is the consumer's, from issue #31, and it is a better argument than the one the design document made. Their monitoring never used this field (liveness runs off their own receipt clock), so the staleness hazard the plan led with could not reach them. But Quote.timestamp is their InfluxDB point time and candles bucket off it — so a lagging venue with a substituted read time puts ticks in the wrong candle, feeding indicators and strategy evaluation, and nothing flags it because the freshness checks are deliberately looking elsewhere.

    :observed_at is mandatory on both types, at the consumer's request. That is what makes a strictly-honest nullable :venue_time affordable: everyone always has a usable time, so nil can mean "the venue did not date this" without forcing a caller to invent a fallback — which would be this same substitution, relocated into consumer code.

    Not a licence to fill :venue_time from a local clock. The rule that field carries is unchanged and absolute: whatever the venue gave us, or nil. dp_exchange_schwab's Streamer book is the model — it reads the venue's snapshot_time and fails closed when absent.

    Trade, Fill, Balance and OrderBookDelta are unchanged and keep a single :timestamp. A sweep confirmed every Candle and Trade construction in all five venues derives its time from a venue field, and OrderBookDelta fails closed without the venue's E. There is no divergence to fix there, and widening a breaking change past the defect it exists for is how a migration becomes unaffordable.

    Design, options and retrospective: docs/design/closed/2026-09-09_venue-time-and-observed-time.md. Announced as issue #31 and answered by the consumer the same day.

Documentation

  • The venue-time design document moved Draft → In Review, and the consumer has been told. docs/design/2026-09-09_venue-time-and-observed-time.md records that Quote and OrderBook carry a single :timestamp and so cannot say "the venue did not date this", which two packages currently resolve by putting a read time in a field the contract documents as the venue's own.

    Filed as issue #31 with the blast radius measured (19 lib/ files, 27 test files, 69 construction sites across six repositories) and the three options costed. The one question that decides between them is put to the consumer directly: does anything measure staleness from Quote.timestamp, or is it carried and stored? If nothing does, the cheapest honest option becomes viable; if something does, the field is actively misleading them today and the fix is worth its cost.

    Nothing lands until they have had a chance to answer, and when it does it is a minor bump across the family in one batch, not a patch — so a consumer pinning three-part, as usage-rules.md instructs, receives it only when they choose it. Both type moduledocs and both offending call sites are already labelled in the meantime.

Documentation

  • Core.Types.Quote and Core.Types.OrderBook now record where their own rule is not kept. Quote's doc says :timestamp is "the venue's own… never invented: a quote whose freshness we cannot state is a quote we must not return." Two venue packages break it, and neither is a decoding mistake: dp_exchange_schwab's LEVELONE_* quotes carry the frame's arrival time, and dp_exchange_gemini's partial-depth books carry the local clock. Both venues genuinely publish no time for those frames — Gemini's own AsyncAPI proves it, requiring [lastUpdateId, bids, asks] for OrderBookSnapshot where BookTicker requires an E.

    The gap is in this contract, not only in those packages. TopOfBook can say "the venue did not stamp this" because it carries :venue_time and :observed_at separately; Quote and OrderBook have one field, so a venue that publishes no time can only lie or drop the data. dp_exchange_schwab's Streamer book is the counter-example that proves the rule is keepable where the venue cooperates — it reads snapshot_time and fails closed without it.

    Recorded in both moduledocs rather than only in a design document, because a reader of the contract deserves to know where it is not being kept.

  • New design document: docs/design/2026-09-09_venue-time-and-observed-time.md. Closing the gap means changing a published type that a live consumer decodes at every call site — 19 lib/ files and 27 test files across six repositories, 69 construction sites, delivered automatically by the release pipeline on merge. This project's rules reserve that for a written plan, and this is a decision where the cheapest option for us is the most expensive one for the consumer.

    Three options are costed: refuse the undated data (deletes the last traded price from Schwab's stream), give Quote/OrderBook what TopOfBook already has (breaking), or add :venue_time alongside :timestamp (non-breaking but redundant, and redundancy rots). The recommendation is the second, sequenced deliberately rather than landed unannounced.

    One open question was closed in the same pass: every Candle and Trade construction in all five venues derives its time from a venue field. None reaches for the local clock, so the substitution is confined to the two named sites rather than being a family-wide habit — which bounds both the data loss of option A and the migration of option B.

Documentation

  • usage-rules/adapter.md gains "If your vendor publishes an index, diff it". The vendor-change design doc concluded that across five vendors a changelog diff caught nothing and an index diff was the only mechanism that ever fired. That conclusion has now produced three real findings — a rate-limit table on developer.webull.com published for weeks behind a five-times-too-permissive ceiling; a WebSocket channel withdrawn from developer.gemini.com with no changelog entry; and Coinbase's rate-limit pages, recorded as "could not be located", sitting in the vendor's own sitemap.xml the whole time.

    Every venue package now carries script/check_endpoint_inventory.sh, weekly and non-blocking. The section records what to compare, in order of preference: a machine-readable specification where the vendor publishes one (Gemini alone today), a sitemap whose pages are one-per-endpoint otherwise (Coinbase, Webull), and — for a vendor that answers 403 to an anonymous reader (Schwab) — nothing, which is a distinct class rather than a degraded one.

    It also records the two rules that separate a check from a rubber stamp: fix the claim before updating the record, because updating the inventory first destroys the only evidence anything changed; and diff the specification, never the rendered page.

  • And the rule the Gemini finding forced: absent from the documentation is not absent from the venue. When something vanishes, what has been established is that the vendor stopped publishing it, not that the venue stopped serving it — Gemini has diverged from its own documentation in both directions. So a withdrawal is a reason to label a claim, not automatically to delete it: deleting asserts a new negative, and an unverified negative is a substitution exactly like an invented value.

Documentation

  • usage-rules/adapter.md gains "Never do blocking work in a process that owes a reply". This is the failure this family has paid for most often, and every instance looked different until they were lined up: #16 and #23 (work on the reply path in a venue's feed), then #28, where Core.PollingFeed ran its fetch in a task and then blocked on that task — the task bounded a hang and did nothing for the mailbox, so a read-only coverage/1 timed out, the exit propagated out of the venue Feed's handle_call/3, and a live venue went from 61 pairs to 0 and stayed there.

    Writing the rule down came with sweeping the family for it, and the sweep found three more instances that had not failed in production yet — a Streamer bootstrap (signed HTTP plus a WebSocket connect) inline in handle_call/3, a whole-catalogue HTTP fetch inline in handle_info/2, and a socket connect inline in handle_call/3. All three are fixed in their own packages.

    The section carries the three mistakes that are easy to make while fixing it: a task that bounds a hang does not unblock a mailbox (that was #28); start_link links to its caller, so a socket opened inside a task dies with the task — fetch in the task, connect in the GenServer; and exceptions must be converted inside the task, because Task.async/1 links and an unconverted raise arrives as an {:EXIT, …} with no clause for it, leaving anything parked on that task unanswered forever.

    It also records the read-side rule the same sweep produced — every venue now passes @call_timeout on coverage/1/status/1, not just on writes, because a health check left on GenServer.call/2's implicit five seconds turns any legitimately busy moment into an exit and a dead consumer process — and the honesty rule for what a degraded read may claim: an empty coverage map plus a :link_down notice, never a remembered one.

Documentation

  • usage-rules/auth.md gains "Credentials are redacted in child_spec/1 — bypass it and they are not". The consumer who verified the issue #29 fix went looking for their canary in their own supervisor's state afterwards and found it: their supervision code builds the child spec itself, for a legitimate reason (a Core.PollingFeed-shaped facade defaults subscriber to self(), which resolves to the supervisor when start_link/1 runs inside init/1, so a different delivery target can only be set at start_link time). On that path child_spec/1 never runs, their supervisor stores the raw map, and OTP renders the live key on the next crash. Upgrading does not fix it, because nothing from any package is on that path.

    No code change was needed or made — wrap/1 and wrap_opt/1 were already public, which is all that path required. What was missing was anyone saying so. Assertion 22 asks whether child_spec/1's own rendering leaks, answers correctly, and is structurally unable to see a spec a consumer built; the section says that explicitly, and tells a consumer on that path to write the consumer-side version of the assertion — including the control case, since a canary test with no proof that an unwrapped map does leak proves nothing.

    It also records the case that actually bit them, which is a step further out than the original bug: a host reshaping a credential (mapping its own api_key/api_secret into a venue's app_key/app_secret) and returning a bare map re-introduces the leak in its own code, downstream of anything a package can reach. Their own canary test caught that on its first run — on Webull, the venue whose keys had actually leaked, which was still leaking after the upgrade through their code rather than ours.

Fixed

  • A read-only coverage/1 could kill a venue's feed — issue #28. Core.PollingFeed ran its fetch inside a task and then blocked on that task inside handle_info. The task bounded a hang; it did nothing at all for the mailbox. With @min_fetch_timeout_ms at 30 seconds against GenServer.call/2's 5-second default, a coverage/1 or status/1 arriving during an ordinary in-flight fetch was not unlucky — it was a guaranteed timeout. In dp_exchange_robinhood that exit propagated out of the venue Feed's own handle_call/3 and killed it; the feed restarted from the static opts its supervisor holds, which never carry a consumer's later subscribe/3, and coverage went 61 pairs to 0 and stayed there — with the process alive, idle and passing every liveness probe. Asking whether the venue was healthy is what made it unhealthy.

    The fetch result now arrives as a message: handle_info starts the task and returns immediately, so coverage/1 and status/1 answer from state at any point during a fetch. Concurrency is deliberately still one — a tick arriving while a fetch is in flight is queued rather than started, which is exactly what the mailbox did when the fetch was synchronous. Letting ticks overlap would have quietly multiplied a venue's request rate the moment a fetch grew slower than its interval, and an unexamined multiplier on request volume is a defect class this family has already paid for. Rescheduling still happens only after a job finishes, so there is at most one pending job per symbol and the queue cannot grow without bound.

    Reported against dp_exchange_robinhood 0.2.21 / dp_exchange_core 0.1.72 by a consumer running the venue live, with the Neighbours block of the crash report showing the called process inside bounded_fetch/2 at the moment the call arrived.

  • The test that should have caught it was the reason nobody did. The existing hang test used a 100 ms :fetch_timeout_ms, so status/1 returned as soon as the fetch was abandoned and the test read the recorded failure — while the call was still blocking for the whole timeout. It is rewritten to use a timeout longer than GenServer.call/2's own default, so a regression cannot return a value at all: it exits, and the test fails instead of passing for the wrong reason. A second test covers coverage/1, which is the call a consumer's health check actually makes.

Added

  • Assertion 22 — credential redaction in child_spec/1 (issue #29). A supervisor stores the {module, :start_link, [opts]} MFA its child spec names, and OTP writes that argument list through inspect/1 into the Start Call: line of the report it logs on any child termination. A raw %{api_key: ..., private_key: ...} map therefore prints its values in full into ordinary application logs — the artifact most likely to be shipped to an aggregator, attached to a bug report or quoted in a ticket. A consumer found live keys exactly this way and nearly pasted them into a GitHub issue while reporting an unrelated bug.

    This is the assertion that assertion 19 says it cannot make. Core.CredentialRedactionCheck proves every struct a package defines redacts on inspect, and its own moduledoc already recorded that it would not have caught the defect as it actually shipped — where the value never became a struct at all. Assertion 22 asks the only question a consumer cares about: having handed the venue a secret the documented way, is that secret visible in what the supervisor stores? It passes every secret key name in the family at once, so it needs no per-venue list, and Kernel.struct/2 drops keys a venue's own struct does not declare — an unrecognised key is gone, not merely unprinted.

    It caught Core.ReferenceVenue — this repository's own example of what a venue looks like — on its first run, which is now fixed and carries the redacting struct as the reference shape.

Changed

  • The purity check (2.10) was policing more than it claimed. It scans _build/#{Mix.env()}/lib/dp_exchange_core/ebin/*.beam, and in :test this project's own elixirc_paths/1 also compiles test/support into that same directory — so a check named "nothing in lib/ reaches for the host application" was quietly also checking test scaffolding. Surfaced when the reference venue grew an @derive {Inspect, ...} and the test failed reporting the Inspect fallback implementation "in lib/", where no such reference exists. It now filters by each beam's own :compile_info source path. Filtering is the repair rather than widening allowed_prefixes: the allow-list is what gives this check teeth for code that actually ships, and adding an entry to satisfy a module that never ships would blunt it for the ones that do.

Documentation

  • docs/design/ideas/detecting-vendor-api-change.md is implemented and closed, as docs/design/closed/2026-09-09_detecting-vendor-api-change.md with a retrospective. The document spent five phases gathering evidence about which change-detector is worth building; the evidence chose, and what shipped into all five venue packages is script/check_doc_sources.sh plus a committed doc-sources.tsv — status and redirect destination per cited vendor documentation URL, recorded on the day a person read it, checked weekly and non-blocking. No changelog watcher and no content differ: across the whole sample a changelog diff caught nothing, and an index diff was the only mechanism that ever fired.

    dp_exchange_core itself gets no manifest and no job — it cites no vendor documentation page, because it talks to no exchange. A no-op weekly run would be noise.

    The instrument found a real defect on its first run rather than the baseline it was meant to record: a 404 on a page dp_exchange_webull cited, which led to a rate ceiling five times too permissive against that venue's own per-endpoint table, on a venue whose documented penalty is 429 and then IP-level blocking. The retrospective carries the full chain, including the finding that matters most here — "the vendor changed" and "we were wrong" share a mechanism, a claim nobody re-read, which is why the check has a manual class for venues no machine can verify (Schwab answers 403 to anonymous readers) and why those rows go STALE past 180 days. That closes a gap this repository named at Phase 5 and could not close: Capabilities has carried measured_at all along and nothing ever read that age.

  • docs/design/ideas/credential-gate-fixed-callback-list.md moved to docs/design/closed/2026-09-07_credential-gate-fixed-callback-list.md. It had been marked resolved on 2026-09-07 and left sitting in ideas/ — a file whose whole purpose is to record state, recording the wrong one. Content unchanged beyond the status line and a note recording the two-day gap.

Fixed

  • AdapterContract no longer dials a live venue on an ordinary mix test run. Assertion 14's get_top_of_book/2 check and two of assertion 12's checks — "catalog_access matches how get_symbols/1 behaves without a query" and "the order-shape claims match what the facade actually answers" (preview_order/3, replace_order/4) — called @venue directly instead of @fake, contradicting the @fake attribute's own comment ("Assertion 12's active-endpoint direction runs against this and never against the real venue") and this family's tier-2 rule ("never on a schedule"). Flagged as an "incidental, pre-existing, out-of-scope finding" in the assertion-20/21 entry below; this is that fix.

    All four now assert @fake is present (failing loudly, matching assertion 17's own pattern, rather than skipping silently — every one of the five venue packages already supplies fake:) and call the fake instead of the real venue. The order-shape check was a second defect beyond the two originally reported: it called preview_order/3 and replace_order/4 unconditionally, with no active/inactive guard at all, so any venue implementing either for real (dp_exchange_coinbase, dp_exchange_schwab, dp_exchange_webull) dialed a signed, order-shaped write endpoint on every run.

    Reproduced and verified against all five venue packages with a path: dependency on this Core, mix test --trace (forces synchronous execution so Core.HttpClient's Logger.debug("HTTP Request: ...") line lines up with the test that triggered it), reverted after each: before this fix, dp_exchange_gemini dialed api.gemini.com/v1/symbols and .../v1/pubticker/btcusd; dp_exchange_robinhood dialed trading.robinhood.com/api/v2/crypto/trading/trading_pairs/; dp_exchange_webull dialed api.webull.com/trading/instruments/crypto/profiles/list; dp_exchange_coinbase dialed api.coinbase.com/api/v3/brokerage/products. After the fix, zero live requests on all four, 0 test failures on all four (Gemini 775, Robinhood 240, Webull 743, Coinbase 682 tests). dp_exchange_schwab made no live request either before or after — it escapes only because preview_order/3 and get_symbols/1 both refuse locally (missing account_hash, missing credentials) before building a request under the specific arguments this suite passes, not because the old code was safe; it is one implementation change away from the same defect the other four had.

Added

  • A conformance-coverage audit — mapping every Core.Venue callback, Capabilities field and Notice kind against the 19 existing assertion groups — closes three real gaps: Capabilities.new/1 gains a has_staking/staking-endpoint agreement check, and AdapterContract gains assertion 20 ("subscribed push shape") and assertion 21 ("historical timeframe discipline"). The full inventory — covered, partial, deliberately declined — lives in docs/reference/core/assertion-coverage.md.

    has_staking must now agree with the six staking endpoints it summarises (get_staking_rates/1, get_staking_balances/1, get_staking_rewards/1, get_staking_history/1, stake/3, unstake/3), the same "Kind 2 redundant field must agree with the endpoints it summarises" rule validate_orders!/1 already applies to supports_order_preview/supports_order_replace/supports_multi_leg_orders. Found live on dp_exchange_coinbase during this audit: has_staking was never declared in its capabilities/0 (defaulting to false) while stake/3 and unstake/3 are real, active calls against Coinbase Prime — a caller branching on has_staking alone would conclude the venue does not stake at all. Confirmed by pointing that package's mix.exs at this Core with a path: dependency and running its own contract test: 23 of 42 tests fail, all from this one raise inside capabilities/0. Reported for that package's own review; not fixed here, and no path: dependency was committed — mix.exs was reverted immediately after. Keyed on an EXPLICIT :proven/:experimental entry in endpoints, never on active?/2's undeclared-is-experimental default, so a declaration that has nothing to do with staking (the overwhelming majority of fixtures and tests in this family) is unaffected. Downstream: every venue package's own test suite re-runs this the moment it depends on this version, since assertion 2 already rebuilds every venue's declaration through Capabilities.new/1.

    Assertion 20, "subscribed push shape": subscribe/2's own doc makes an unconditional claim — the payload is a DpExchange.Core.Types.* struct, tagged with runtime_id/0 — that nothing checked before now. Assertion 16 (internal wiring) catches a decoder with no caller, but a decoder that IS wired and simply never gets called before the raw response reaches the sink passes every other assertion. Fake-only, so it never dials out.

    Assertion 21, "historical timeframe discipline": get_historical_prices/4's own doc states this family's own named recurring failure mode verbatim ("The venue rejects a timeframe it does not serve rather than substituting the nearest one") and nothing checked it before now. Picks a width from Timeframe.nameable/0 the venue's own historical_timeframes does not name and asks the fake for it; the answer must not be {:ok, _} (dp_exchange_robinhood declares the endpoint :unsupported entirely and the check is a no-op there). Fake-only.

    Both 20 and 21 pass on all five real venue packages — verified the same way as the has_staking finding above, a path: dependency run and reverted per venue — with dp_exchange_coinbase's 23 failures being entirely the has_staking finding, not these two. See docs/reference/core/assertion-coverage.md's "Five-venue verification" section for the full table, including an incidental, pre-existing, out-of-scope finding from the same runs: two OLDER assertions (14 and part of 12) call the real venue module directly for get_top_of_book/2 and get_symbols/1, so an ordinary mix test on Gemini, Robinhood and Webull today makes real calls to each venue's public API despite test_helper.exs excluding :tier2 specifically to prevent that.

    usage-rules/testing.md and docs/guides/building-an-exchange-package.md's assertion counts move from 19 to 21; usage-rules/adapter.md gains sections for both new assertions next to assertion 19's.

  • usage-rules/adapter.md gains a "Dependency floors are a claim exactly like a capability" section, and every venue package gains script/check_dependency_floor.sh plus a weekly floor-check.yml workflow. Four real instances of the same defect shipped in one week: a mix.exs ~> requirement that compiles and passes CI (which always resolves the newest allowed version) while permitting an older, still-allowed version the code does not actually run against — dp_exchange_webull/websockex (twice — the first correction, ~> 0.4 → ~> 0.5, was itself still wrong; send_frame/3 needs 0.5.1, not 0.5.0), dp_exchange_webull/dp_exchange_core (no_venue_contact, needs 0.1.68), and dp_exchange_gemini/dp_exchange_core (Types.OrderBookDelta, needs 0.1.53). A per-API pinning test guards a floor against being lowered again; it cannot catch a floor that was wrong when written, because it only knows what its author already thought to check. The script resolves every dependency a venue declares without only: down to the exact floor its own requirement names, then runs mix compile --warnings-as-errors plus the package's own AdapterContract conformance test against that pinned set — deliberately not the full mix test (dp_exchange_coinbase's feed_test.exs was found, in the course of this work, to depend on the real live venue — a separate, pre-existing issue, filed, not fixed, here) and deliberately not extended to dev/test-only tooling (credo at its own declared floor, 1.7.0, does not compile under Elixir 1.18 at all — a bug in a years-old release unrelated to this family, which would make the check permanently red for a reason that has nothing to do with any package's floor). Runs weekly plus workflow_dispatch, never on push/pull_request and never in ci.yml's publish job's needs: chain, because a fresh resolve floats each pinned dependency's own transitive tree to whatever is newest on Hex today — a red run can be caused by an unrelated package's release, not by this repository, and this family has already relearned once that a check people learn to ignore is worse than none. Full writeup, evidence and the decisions not taken (a frozen lockfile; a full test run) in docs/design/closed/2026-09-08_dependency-floor-check.md.

  • AdapterContract gains assertion 19, "credential redaction" — a struct defined in a package's own lib/ holding a secret-named field must redact it under inspect/1. Found live in four of five venue packages on 2026-09-07: Feed/Socket held :credentials as a bare map for their entire lifetime, and OTP's default crash report prints a process's state in full on termination — a plain map prints every key it holds, secrets included. Proven by crashing an equivalent process holding %{api_key: "...", api_secret: "..."} as a bare state field and reading the log back. A second leak was found the same way: a FunctionClauseError's stacktrace prints the actual arguments a failed clause was called with, so a bad call handing the same raw map to a function whose every clause failed to match printed it too. Process.flag(:sensitive, true) was tried and ruled out — it changes what :sys.get_state/1/:dbg can see, not how a crash report or a stacktrace is formatted. Fixed identically in four repos, by wrapping the credential in a struct whose Inspect is derived with except: naming every secret field, at the point it enters a long-lived process: dp_exchange_coinbase 4d00669 (api_key, api_secret), dp_exchange_webull 80eaf02 (app_key, app_secret, access_token), dp_exchange_schwab 336cbd8 (access_token, refresh_token, client_id, client_secret), dp_exchange_robinhood cfc4861 (api_key, private_key). dp_exchange_gemini needed no fix — it signs and discards inside stateless pipelines and holds no credential in process state at all.

    Verified behaviourally, not by looking for @derive in a venue's source. DpExchange.Core.CredentialRedactionCheck instantiates a real copy of every struct a package's lib/ defines (struct/2, never struct!/2, so @enforce_keys on an unrelated field never blocks construction) with a distinctive value in each secret-named field, and searches the actual rendered inspect/1 output for it. A hand-written defimpl Inspect, for: YourStruct that never mentions @derive at all passes exactly as validly as the derived form every real fix used, because this checks what a struct prints, never how it was made to print that way. Every module under test is already loaded by construction — this only ever runs from inside the same mix test invocation that compiled the package being checked, so nothing here starts a fresh process or touches a network.

    The secret-name list has fifteen entries: api_key, api_secret, app_key, app_secret, secret, password, passphrase, token, access_token, refresh_token, client_secret, private_key, signature, authorization, bearer — matched against a struct field's own name exactly, never a substring, so token_type is not caught by token. Twelve are DpExchange.Core.Notice's own @credential_keys, reused rather than reinvented — the same question ("is this name, alone, secret-shaped") already answered there for a different purpose. Three extend it because a real, shipped Credentials struct in this family has a field by that name and Notice's list did not cover it: app_key/app_secret (dp_exchange_webull) and client_secret (dp_exchange_schwab). client_id is deliberately excluded even though Schwab's own struct redacts it too — an OAuth client_id is a public identifier by the convention the spec itself follows, and treating it as inherently secret would be exactly the "too broad" failure mode that gets a check disabled. Verified against every defstruct in all five venues' actual lib/ before the list was finalised: none collides with any of the twelve Notice-derived names.

    Be honest about what this does not catch: the original defect was a raw map, never a struct, and this check would not have caught it as it actually shipped. Feed/Socket, pre-fix, held Keyword.get(opts, :credentials) directly in state — an opaque value never constructed as a literal anywhere in either module's own compiled code. A struct-field check locks the fix in; it cannot reach back to the shape of the bug before the fix existed. Two static, map-shaped versions of a broader check were considered and rejected as the enforceable maximum instead: (1) "a process-behaviour module's compiled code contains a map literal with a secret-named key" does not reach the real pre-fix defect at all (the offending modules never constructed the map as a literal) and fires constantly on completely correct code — every venue's Auth module builds a transient map or keyword list with a secret-named key to hand to an HTTP client or a signer, never storing it; (2) "reads :credentials from start options and stores it without first passing it through a wrapping call" is closer to the real shape but only detectable by assuming every future fix uses a sibling module conventionally named *.Credentials with a wrap/1 function — encoding an implementation convention into a Core assertion the same way AdapterContract's own "no assertion may name a socket, a channel string, a transport module or a polling interval" already forbids in the transport direction. Both fail the test this family already applies to a candidate assertion — would it survive contact with real, correct code across all five venues without being disabled — so the struct-field check is documented as the narrower thing that is enforceable, not manufactured as broader coverage this contract does not actually have.

    Proven against a reconstructed pre-fix shape in test/dp_exchange/core/ credential_redaction_check_test.exs (a struct with no Inspect override at all, fails; the same struct with @derive {Inspect, except: [...]} added, passes; a hand-written defimpl Inspect that redacts, passes; one that does not, still fails; a plain map holding the same secret-named key, never flagged, proving the documented limitation directly rather than only in prose) — no venue repo was touched to prove this. Verified against all five real venues, each pointed at this Core checkout with a temporary path: dependency, mix test run, then reverted before anything was committed: all five pass today — dp_exchange_coinbase (676 tests), dp_exchange_webull (735 tests), dp_exchange_schwab (495 tests), dp_exchange_robinhood (236 tests), dp_exchange_gemini (772 tests) — all 0 failures, because all five already carry today's fix (or, for Gemini, never needed one).

    This package's own mix.exs gains consolidate_protocols: Mix.env() != :test: the new check's own test fixtures compile a struct's Inspect implementation at test runtime via Code.compile_string/2, after mix test's default protocol consolidation has already baked a dispatch table that does not know that implementation exists yet — a real venue package never hits this, since its Credentials module compiles as part of the ordinary mix compile pass, before consolidation runs.

    Assertion count is now nineteen. usage-rules/testing.md, usage-rules/adapter.md (a dedicated section next to assertion 18's) and docs/guides/building-an-exchange-package.md updated.

    Affects all five venue packages on their next Core bump, and every future one. Inert today for all five — four already carry their own independent fix, and Gemini never needed one — so this assertion exists to stop a sixth venue (or a regression in one of the five) reintroducing the exact shape found on 2026-09-07, not to fail anything that ships today.

  • AdapterContract gains assertion 18, "link safety" — a process behaviour (GenServer, :gen_statem, GenStateMachine, WebSockex) that links a child it starts must also call Process.flag(:trap_exit, true). Found live in four of five venue packages on 2026-09-07, all independently: Feed.init/1 never trapped exits, and Socket.start_link/1 (or PollingFeed.start_link/1) ran from inside a Feed callback, which links the child to Feed itself rather than to a supervisor. An abnormal exit on that link was therefore untrappable and crashed Feed, and the venue's Supervisor restarted it from its static start opts — every subscribe/2 a consumer had made since boot, gone in the same instant. One socket dying anywhere took the whole feed's subscription state with it. Fixed identically in all five repos by trapping exits before anything gets linked: dp_exchange_coinbase e77b542, dp_exchange_gemini 66acd3b, dp_exchange_webull d0c54a8, dp_exchange_schwab 90dddc6, dp_exchange_robinhood 51ad189.

    Deliberately static, not the behavioural "start the tree and kill a linked child" test that was designed first. That design was rejected on evidence, not on general principle: starting a venue's real (non-fake) tree is not reliably network-free. dp_exchange_schwab's Feed dials its Streamer unconditionally from init/1's own {:continue, :connect}, regardless of whether any symbol has ever been subscribed — confirmed by starting its real tree under the local Core path dependency below and reading the request that goes out. The only way to prevent that dial without opening a real socket is to inject an already-open stand-in through an option each venue happens to expose for its own tests (:socket here, differently named or absent on dp_exchange_robinhood, which has no socket concept at all) — which is exactly what this suite's own rule forbids: no assertion may name a socket, a channel string, a transport module or a polling interval. There is also no reliable, venue-agnostic way to locate "the feed" once a tree is running — DpExchange.Core.FeedBehaviour exists for exactly that and has zero adopters across the five.

    DpExchange.Core.LinkSafetyCheck instead reads each candidate module's own compiled abstract code — the same :beam_lib technique Core.UnwiredCheck already uses for assertion 16 — for two facts, module-wide rather than per-callback (the five real fixes disagree on which callback creates the link and which calls Process.flag(:trap_exit, true), so the invariant is checked against the module as a whole): a link-creating call (start_link on any target, Process.link/1, or spawn_link, any arity) and the trap_exit guard. Neither is ever a process; nothing here starts, so nothing here can dial out, for any venue present or future. start_link/1, start_link/2, child_spec/1 and child_spec/2 are excluded as the source of a link — the same two names assertion 16 already excludes, and for the same reason: found running this check against the real, compiled dp_exchange_coinbase, Socket.start_link/1 (use WebSockex) delegates to WebSockex.start_link/4 to bootstrap itself, a call literally named start_link on every process-behaviour module in the family, always — that link belongs to whoever calls Socket.start_link/1, not to Socket.

    Proven against a reconstructed pre-fix shape in test/dp_exchange/core/ link_safety_check_test.exs (a GenServer linking a socket from handle_call/3 with no trap_exit, fails; the same shape with Process.flag(:trap_exit, true) added, passes) — no venue repo was touched to prove this. Verified against all five real venues, each pointed at this Core checkout with a temporary path: dependency, mix test run, then reverted before anything was committed: all five pass today — dp_exchange_coinbase (668 tests), dp_exchange_gemini (771 tests), dp_exchange_webull (725 tests), dp_exchange_schwab (484 tests — the one venue whose real tree is not network-free, and the reason this assertion is static: it passed without ever starting a process, so nothing in it could have dialed out), dp_exchange_robinhood (228 tests) — all 0 failures, because all five already carry today's fix.

    Assertion count is now eighteen. usage-rules/testing.md, usage-rules/adapter.md (a dedicated section next to assertion 17's) and docs/guides/building-an-exchange-package.md updated.

    Affects all five venue packages on their next Core bump, and every future one. Inert today for all five — each already carries its own independent fix — so this assertion exists to stop a sixth venue (or a regression in one of the five) reintroducing the exact shape found on 2026-09-07, not to fail anything that ships today.

  • AdapterContract gains assertion 17, "credential gate" — on a venue declaring credential_benefit: :required, no active credentialed endpoint's fake: may answer {:ok, _} when called with credentials stripped. Found independently in two venue packages the same week: dp_exchange_robinhood's fake answered {:ok, _} from six credentialed functions (get_balances/2, get_accounts/2, place_order/3, cancel_order/3, get_order/3, get_orders/2) regardless of what credentials they were given, on a venue where every request is signed and there is no anonymous endpoint — the fake was lying about the most basic property of the venue. A separate fake/real error-shape divergence was found the same day in dp_exchange_gemini. Tier 1 in-process fakes are the only tier that runs on every CI run and the only one most consumers ever exercise, so a fake more capable than the real venue silently certifies consumer code that forgot to supply credentials.

    Fake-only — it never dials the real venue, so it carries none of the risk a live-network assertion would — and gated strictly on credential_benefit: :required, not run unconditionally: a venue declaring :no_difference or :higher_ceiling may legitimately serve some of these endpoints without a credential, and asserting a refusal there would invent a rule the venue never claimed. test_connection/2 and get_rate_limit_status/2 are excluded from the gate on any venue, :required or not — both callbacks document credentials() | nil on purpose, and answering plain reachability with none at all is the documented behaviour, not the defect this assertion exists to catch.

    Deliberately does not assert real/fake refusal-shape equality (call_on(@venue, stripped) == call_on(@fake, stripped)), which was the second, stronger proposal and would also have caught Robinhood's {:refused, :missing_credentials} vs. the real venue's {:error, {:missing_credentials, :robinhood}}. That check is only safe while every venue's auth check fails locally before any HTTP dial-out — true today, but Core would be assuming an invariant about a venue it has not reviewed, and a conformance assertion that can make a live network call under some future venue's implementation is a worse failure mode than the gap it would close. Verified against the real generated assertion, not only a manual replica: temporarily setting credential_benefit: :required on ReferenceVenue (whose get_balances/2 ignores its credentials argument, correctly, for its real :higher_ceiling declaration) makes assertion 17 fail with {:get_trade_history, 2} answered {:ok, _} with credentials stripped, confirming the check fires on real generated code before being reverted; contract_teeth_test.exs carries the permanent regression fixtures (Broken.CredentialGate.NeverChecks and .Conforming).

    Assertion count is now seventeen. usage-rules/testing.md and docs/guides/building-an-exchange-package.md updated; usage-rules/adapter.md gains a dedicated section next to assertion 16's.

    Potentially breaking for any venue package declaring credential_benefit: :required whose fake does not already gate every credentialed endpoint on its credentials argument. Of the five, this specifically means dp_exchange_robinhood (the venue this defect was found in) will exercise this assertion for real on its next Core bump; whether it still fails depends on whether that package's own fake fix has landed by then. The other four venues do not currently declare credential_benefit: :required (per this repo's own review of their capabilities/0), so this assertion is inert for them today and only bites if one of them adopts :required without also gating its fake.

Changed

  • Assertion 17's gate widened from a fixed list of eleven callback names to every active endpoint on a credential_benefit: :required venue, minus a two-name exemption. The assertion count does not change — this widens 17, it does not add a new one.

    The narrow gate (@credentialed, unchanged as a name — it still governs argument SHAPE for every assertion that builds call args, positional vs. opts) could only ever see a callback that takes credentials as its own first positional argument. A callback that reads a credential out of opts instead — get_option_chain/2, get_news/1, get_corporate_events/1 and quantization/1 are the ones a venue that signs every request actually uses this shape for — was invisible to it no matter how its fake answered with no credential. dp_exchange_webull and dp_exchange_schwab each found and hand-fixed exactly this defect in their own fakes on 2026-09-07, the same day assertion 17 first shipped, and both said the durable fix belonged here — this closes that blind spot rather than leaving it as a documented limitation.

    The rule now enforced: :required means every active endpoint needs a credential, so the assertion checks all of them — Venue.behaviour_info(:callbacks) minus child_spec/1 and start_link/1 (excluded via the same answerable?/1 this suite already uses elsewhere; calling start_link on even a fake risks starting a real process, which no assertion here should ever do) minus @credential_gate_exempt, which names exactly two: test_connection/2 and get_rate_limit_status/2. Both are exempt for the same, unchanged reason — their own callback doc types the credential credentials() | nil, a statement from the CONTRACT itself, not a venue's implementation choice, that a missing credential is expected there, because both mean "can I reach the venue at all" rather than "give me this venue's data". Every callback that can answer {:ok, _} at all is now checked; a callback whose return type can never match that pattern (capabilities/0, coverage/1, subscribe/2 and the rest of the streaming surface, which return a bare map or :ok/{:error, _} rather than a result() tuple) passes trivially by construction, which costs nothing and needed no separate exclusion.

    Stripping now actually strips opts, not only the positional argument. The previous version sent [] for every :opts position, credentialed shape or not — the same [] endpoint_args/2 already sends for an ordinary call, so an opts-carried credential was never exercised at all, stripped or not, and a check built on it would have proven nothing. stripped_arg_value(:opts) now sends [credentials: %{}], an EXPLICIT empty credential, so a venue whose facade reads Keyword.get(opts, :credentials, %{}) is actually exercised with a value it has to branch on rather than a key its own code may never have read at all.

    Verified against all five real venues, each pointed at this Core checkout with a temporary path: dependency, mix test run, then reverted before anything was committed. Only the three venues declaring credential_benefit: :required can exercise this assertion at all:

    • dp_exchange_schwab (495 tests) — 0 failures. Every newly-included endpoint, including market_status/1 (which this venue's fake correctly gates on a credential, since the real venue's market-hours endpoint is itself authenticated), already refuses without one.
    • dp_exchange_webull (736 tests) — 1 new failure: {:market_status, 1} answers {:ok, :open} unconditionally, because this venue is crypto-only and the real facade never calls out for it at all — market_status/1's own contract doc says crypto venues answer :open always, so nothing about this endpoint reads opts or dials the venue, credentialed or not.
    • dp_exchange_robinhood (236 tests) — 1 new failure, the identical shape: {:market_status, 1} answers {:ok, :open} unconditionally for the same crypto-only reason.

    dp_exchange_coinbase (:higher_ceiling) and dp_exchange_gemini (:no_difference) do not declare :required, so assertion 17 does not run for either and both pass unchanged (679 and 772 tests respectively, 0 failures) — confirming the widening touches only the :required path and nothing else in either package.

    market_status/1 on the two crypto venues is reported here as a finding, not fixed in this change. Whether it belongs on a per-venue exemption list, or whether Webull's and Robinhood's credential_benefit: :required overstates a venue where at least one endpoint is genuinely credential-free by design, is a call for each venue's own maintainers — this package does not fix venue repos from inside a Core change, and an argued, NAMED exception belongs in that venue's own review, not folded silently into this assertion's exempt list on Core's say-so alone.

    Breaking for dp_exchange_webull and dp_exchange_robinhood on their next Core bump, until each resolves the market_status/1 finding above. Not breaking for dp_exchange_schwab, dp_exchange_coinbase or dp_exchange_gemini — all three pass today exactly as they did before this change.

    usage-rules/adapter.md's assertion 17 section rewritten to describe the widened rule and both exemptions by name.

  • The market_status/1 finding left open by the widening above is resolved — not by adding it to @credential_gate_exempt. dp_exchange_schwab's real market_status/1 calls an authenticated /markets endpoint and its fake correctly refuses without a credential; a name-based exemption (the same mechanism test_connection/2 and get_rate_limit_status/2 use) would have silenced that protection to accommodate two venues where the callback currently answers without ever touching one — the exact "decorative check" this suite exists to avoid.

    Added a second, narrower exemption instead, scoped to what market_status/1's own callback doc actually claims ("crypto venues answer :open"): the credential gate now also skips this one callback when @venue.asset_classes() == [:crypto]. Crypto has no exchange-mandated trading session for a credential to gate, so the claim is true of the asset class, not fetched from the venue — a fact no credential can change. A venue serving anything else stays gated on market_status/1 exactly as before.

    Verified against the same three :required venues that exercised the widening, pointed at this Core checkout with a temporary path: dependency, mix test run, then reverted before anything was committed:

    • dp_exchange_schwab — unaffected; asset_classes/0 is not [:crypto], so market_status/1 is checked exactly as before, and its fake already passes.
    • dp_exchange_robinhood — now passes without any code change on its side: its asset_classes/0 is [:crypto], so the new exemption reaches its unconditional {:ok, :open} and the finding closes. Its own repo still records a stated reason for that answer, per its own review.
    • dp_exchange_webull — still fails, correctly: asset_classes/0 is [:crypto, :equity, :option, :future, :event_contract], not [:crypto], so the new exemption does not reach it and market_status/1 remains gated. Resolved in that package's own repo by declaring the endpoint :unsupported — its OpenAPI documents no market-status or trading-calendar call, and the one such endpoint Webull publishes anywhere belongs to a separate Broker API product this package cannot reach.

    A teeth test added to contract_teeth_test.exs pins both directions with fixture venues: a crypto-only :required venue answering market_status/1 unconditionally must NOT be flagged, and the identical fixture serving one more asset class must be.

    DpExchange.Core.Venue's market_status/1 doc and usage-rules/adapter.md's assertion 17 section both updated to state the resolution and its reasoning.

  • A second assertion 17 finding, this one a false positive that drove a wrong fix downstream: dp_exchange_webull's get_fees/2 legitimately answers {:ok, _} with credentials stripped, because it makes no venue call at all — it returns a flat crypto spread rate captured from the venue's own published pricing (source: :published_rate). Neither exemption above covers it: it is not a test_connection/2-style reachability check (name-based), and it is not exempt by asset class (market_status_crypto_exempt?/2's ground) — Webull is not crypto-only. Assertion 17's then-unqualified rule flagged it anyway, and a 2026-09-06 sweep on dp_exchange_webull "fixed" the finding by gating get_fees/2 behind a credential it never used, reasoning that the real path "had never run through Auth.headers/2" — true, and the reason there was nothing to gate. That broke a real consumer who resolves venue fees to score candidate strategy genomes before any account is attached, no credential existing at that point by design: an assertion driving a wrong fix is worse than no assertion.

    Capabilities gains no_venue_contact, a list of {name, arity} a venue declares when a specific active endpoint's real implementation never builds a request to the venue — the same per-endpoint shape endpoints already uses, so it cannot rot into one more hand-maintained name list the way @credentialed did. no_venue_contact?/2 reads it; assertion 17 now also skips an endpoint declared there. This is narrower than credential_benefit, which is a claim about the venue in general — declaring an endpoint here is a claim the venue package must be able to point at real code to back, and a wrong declaration defeats the same protection a wrong credential_benefit would. Full argument in Capabilities's own moduledoc and AdapterContract's "17. credential gate" comment.

    Verified against all five venue packages, each pointed at this Core checkout with a temporary path: dependency, mix test run, then reverted before anything was committed: dp_exchange_coinbase, dp_exchange_gemini, dp_exchange_robinhood and dp_exchange_schwab are unaffected (none declares no_venue_contact and assertion 17 behaves exactly as before). dp_exchange_webull needs its own fix — get_fees/2 ungated on both Rest and Fake, and {:get_fees, 2} added to its no_venue_contact declaration — landed in that package's own repo.

    Teeth tests added to contract_teeth_test.exs pinning both directions with fixture venues: a :required venue whose endpoint is declared in no_venue_contact and answers unconditionally must NOT be flagged, and the identical fixture with the endpoint undeclared must be.

  • PollingFeed's own test suite no longer sleeps a guessed duration to synchronise on a timer-driven poll cycle. Ten Process.sleep/1 calls used purely as a wait-then- assert device are replaced with assert_receive on a message the module already sends (on_notice, which delivering_nothing?/2 fires on the very first failed tick for every single-symbol fixture these tests use, or on_refusal) or a synchronous PollingFeed.status/1 / coverage/1 call issued right after the event under test — a GenServer.call cannot reply until every message queued ahead of it has been handled, so a reply is itself deterministic proof the feed processed the prior event (and did not crash doing it) rather than a bet that a fixed number of milliseconds was enough. Three Process.sleep/1 calls are unchanged and were never a synchronisation device: two Process.sleep(:infinity) calls and one Process.sleep(20) are the simulated venue latency and hang under test, inside the fetch functions passed into PollingFeed, not a wait on its output. Verified with no new flakes across eight consecutive runs plus three additional seeds. No wait_until-style bounded-retry helper was needed in the end — every case had a real message or a synchronous call to wait on instead, which this suite's own preference (a real observable over polling for one) already ranks above a retry loop.

Fixed

  • Timeframe.nameable/0 was missing 1y, the same way it was once missing 1w and 1M. dp_exchange_webull's stock, option and futures bars genuinely serve a yearly candle alongside the weekly and monthly ones (Rest.get_stock_bars/5, tested against the venue's own timespan enum), but Capabilities.new/1 raised on 1y the way it used to raise on 1w/1M before those were added — the exact under-declaration this module's own moduledoc already records twice over. Webull carried @core_unnameable_widths ~w(1y), subtracted from its historical_timeframes declaration with a comment naming this exact gap as a Core limitation rather than an under-declaration on its own part.

    @unbucketable is now ~w(1w 1M 1y) — a year is not a fixed number of seconds any more than a month is, and seconds/1/aligned?/2/boundary/2 treat it exactly as they already treat the other two: no boundary rule, never rejected as invalid. known/0 is unaffected; only nameable/0 (and therefore what Capabilities.new/1 will accept in historical_timeframes) widens.

    Additive, not breaking: every existing valid historical_timeframes declaration remains valid, since nameable/0 only grew. Unblocks dp_exchange_webull declaring its eleventh width — its @core_unnameable_widths workaround and the subtraction using it are removable once it takes this version.

  • AdapterContract's assertion 12 ("an active endpoint does not answer :not_supported") only checked endpoints EXPLICITLY present in capabilities().endpoints — an endpoint never mentioned there at all slipped past it, even though Capabilities's own moduledoc makes an absent entry active too ("anything not named in the map is :experimental — the only honest default"). Capabilities.endpoints_at/2 iterates only the map's explicit entries by design (its own @doc says "every endpoint DECLARED at maturity"), so Capabilities.endpoints_at(caps, :proven) ++ Capabilities.endpoints_at(caps, :experimental) — the set the assertion used to check — never contained an endpoint a venue simply never declared. A venue implementing a stub that returns {:error, :not_supported} for, say, get_fx_rate/3, while never entering {:get_fx_rate, 3} into endpoints at all, is under-declaring by silence rather than by a wrong value — and passed the exact check built to catch under-declaring, because that check only ever looked at what was explicitly written down. core_endpoints/0's own "every core endpoint carries an explicit maturity" test closes this for the ~16 endpoints named there; it does not touch the other ~70 a venue is free to leave undeclared.

    Fixed by enumerating Venue.behaviour_info(:callbacks) and asking Capabilities.active?/2 directly, rather than enumerating endpoints_at/2's two lists. active?/2 already applies the documented undeclared-is-experimental default, so this closes the gap without changing what "active" means — it changes what gets CHECKED against that meaning. DpExchange.Core.ReferenceVenue declares every single callback explicitly (see endpoint_maturities/0), so Core's own conformance run is unaffected; Broken.SilentlyUnsupported in contract_teeth_test.exs reproduces the gap and proves the fix closes it.

    Potentially breaking for all five venue packages (dp_exchange_coinbase, dp_exchange_gemini, dp_exchange_robinhood, dp_exchange_schwab, dp_exchange_webull): any of them relying on the documented undeclared-default for a peripheral endpoint while that endpoint's implementation genuinely answers {:error, :not_supported} will now fail this assertion in their own CI, where it previously passed silently. That failure is correct — it is exactly the under-declaring defect assertion 12 exists to catch — and the fix is to declare the endpoint :unsupported explicitly, not to weaken the check.

  • PollingFeed's :fetch_all path crashed the whole feed process on a {:refused, _} return — the one outcome fetch_all_and_publish/1's case statement did not match — instead of recording one refused symbol. dp_exchange_robinhood's Feed moduledoc documented this exact gap as the reason it stayed on per-symbol :fetch rather than adopt this venue's own documented repeatable-query bulk endpoint (?symbol=BTC-USD& symbol=ETH-USD in one signed request): "a {:refused, _} returned from :fetch_all does not match either clause fetch_all_and_publish/1 handles and would crash this feed's process instead of recording one refused symbol." Verified by reproducing it: a fetch_all returning {:refused, _} raised CaseClauseError inside handle_info, taking the GenServer down.

    t:PollingFeed.fetch_all/0 — previously undocumented as a type at all — now names a third outcome, {:refused, refusals} where refusals :: [{symbol, reason}], the batch analogue of fetch's own {:refused, reason}: each named symbol is reported once through on_refusal, exactly as the per-symbol path already does, instead of being retried forever as an ordinary {:error, reason} would be.

  • PollingFeed's :fetch_all path could deliver {:ok, []} forever without ever tripping the "delivered NOTHING" escalation. record_success(state, false) — the clause a zero-event bulk response routed through — was a silent no-op: it never called delivering_nothing?/2, so a bulk venue answering successfully with an empty result set every cycle (a bad credential filtered to nothing server-side, for one) produced no log line and no on_notice, the exact silent-failure shape this module's moduledoc names as the reason the escalation exists at all. {:ok, []} now routes through record_failure/3 with reason :empty_response, so it is counted, logged and escalated the same as any other empty cycle. record_success/2's now-unreachable false clause is removed; every remaining call site always delivered something, so it is record_success/1.

  • HttpClient's retry backoff hardcoded 4 - attempts_left, assuming the default retry_attempts of 3. retry_attempts is a documented, caller-configurable option; configuring it to 4 or more starts attempts_left above 4, so 4 - attempts_left goes negative on the very first retry and Process.sleep/1 raises FunctionClauseError — in the CALLING process, uncaught, since this library does not supervise its callers. The same failure shape this module's moduledoc already records for retry_attempts: nil (4 - nil via Erlang term ordering), reachable here for a valid, in-range, documented integer instead. No existing test used retry_attempts above the default, so nothing caught it. Fixed by scaling the backoff from attempts actually made (retry_attempts - attempts_left + 1) rather than a constant tied to the default — always >= 1 regardless of configuration, and numerically identical to the old formula's own output at the default of 3.

  • Notice.new/3 validated kind against the closed vocabulary but never validated severity, despite severity being documented as equally closed ("not a log level — a call to action"). Notice.new(:link_down, :v, severity: :critical) silently built a %Notice{severity: :critical} outside its own t:Notice.severity/0 typespec. severity is now checked against [:info, :warning, :error], raising ArgumentError the same way an unknown kind already did.

  • Notice.new/3 read :severity, :at and :details with Keyword.get/3, which does not substitute its default for a PRESENT-and-nil value — the same trap DpExchange.Core.Config.opt/3, PollingFeed and HttpClient have each paid for separately. A caller forwarding its own options (or computing a value and getting nil back in an edge case) could produce severity: nil (a struct violating its own typespec, previously unvalidated besides), at: nil (violating @enforce_keys' own non-nil promise, the same way a Core.Types.* decode bug does — see Types.Validate), or details: nil (raising "must be a map, got nil" for what is, from a forwarding caller's side, simply an unset optional field). All three now read through DpExchange.Core.Config.opt/3: an explicit nil falls back to the same default an absent key already used.

  • Core.Types.Trade.new/1 accepted an explicit broken: nil, bypassing the struct's own documented default (false) and typespec (boolean(), never boolean() | nil). :broken is deliberately not @enforce_keys'd — an omitted value should default to false, and that part worked — but @enforce_keys guards presence, not nil (see Types.Validate), so a PRESENT broken: nil (the shape a JSON decode produces from a venue field that came back null) reached the struct unchanged. nil and false are both falsy in a bare if, which is exactly why nothing had noticed; a case matching true and false with no third clause does not get that courtesy, and :broken is the field a phantom high or low rides in on. new/1 now normalises an explicit nil to false — this type's own moduledoc already says false means "the venue said not broken or said nothing," so this is the documented policy applied consistently rather than a new judgement call.

  • Core.Types.StakingBalance's :by_provider defaulted to nil via a bare defstruct entry, though its typespec is a bare map (%{optional(String.t()) => Decimal.t()}, never | nil) and its own moduledoc states "empty means the venue does not break the position down" — a promise only true if %{} is what a caller actually gets. Both an omitted :by_provider and an explicit by_provider: nil produced %StakingBalance{by_provider: nil}, a value nothing downstream could safely Map.get/2 or iterate the way the typespec promises. defstruct now defaults by_provider: %{}, and new/1 normalises an explicit nil to %{} the same way, consistent with Trade.broken's fix above.

  • DpExchange.Core.Config.resolve_snapshot/3 hardcoded Application.get_env(:dp_exchange_core, key, default) regardless of what a caller passed, despite its own moduledoc claiming it falls back to application env "exactly as get/3 does" — and get/3 takes app as an argument. A venue package snapshotting one of its OWN seams (DpExchange.Core.Config.snapshot/1, which is app-agnostic — process-scoped overrides are keyed only by key) and resolving it inside its own GenServer would have had this function consult Core's application env instead of its own, silently never finding a value its consumer configured no matter how it was set. Found with no live caller yet — every known consumer (dp_exchange_schwab's poller) reapplies a snapshot with put_override/2 in a loop rather than calling this — so the mismatch between the documented behaviour and the hardcoded app went unnoticed. Fixed before a first caller could inherit it.

    Breaking, in signature only: resolve_snapshot/3 is now resolve_snapshot/4, taking app as its second argument (resolve_snapshot(snapshot, app, key, default)), matching get(app, key, default)'s own order. No known caller in any of the five venue packages uses this function today, so the practical impact is expected to be zero, but a positional call written against the old three-argument form will not compile against this version.

  • Core.Instrument.new/1 built its struct with plain struct!/2 rather than Types.Validate.new!/3, so an explicit symbol: nil — @enforce_keys guards presence, not nil — built an %Instrument{symbol: nil} violating its own symbol: String.t() typespec, the one field this whole type exists to attach base/quote/status/type to. Every Core.Types.* struct already routes its new/1 through Validate.new!/3; Instrument (outside the Core.Types.* namespace, but carrying the identical @enforce_keys-guards-presence-not-nil shape) did not. Fixed to match the family convention.

Added

  • Core.AdapterContract gains assertion 16, "internal wiring" — every internal export must have a caller inside the package's own lib/, catching the family's single most-repeated defect: a mechanism built, documented, and never wired. Six instances in one week, every one shipped green because a test called the function directly and coverage stayed high: rate_limit_blocking plumbed through Core.HttpClient but never set by the caller (dp_exchange_robinhood issue #16, dp_exchange_webull issue #23 — three separate option allowlists, a fix stopping at the first still passed every test asserting the keyword was present — dp_exchange_coinbase issue #26); FrameSender's retry path in dp_exchange_coinbase, reported but never retried (issue #22); dp_exchange_schwab's subscribe_notices/1 facade, discarding opts[:to] instead of reaching Feed's notice registry; dp_exchange_schwab's Auth.refresh/2, zero call sites in lib/ while Socket held a token good for 30 minutes and websockex reconnected with no delay of its own.

    DpExchange.Core.UnwiredCheck is the engine: it reads :xref's real call graph (E, the same OTP tool assertion 7's purity check already reads imports chunks through), not a grep — a captured &Mod.fun/1 and a literal apply(Mod, :fun, args) both count as real usage. Excludes, without a hand-maintained allowlist: the facade and fake (@venue/@fake, already bound for every other assertion), every behaviour a module declares (read from its own :attributes chunk and that behaviour's own behaviour_info(:callbacks) — GenServer, WebSockex, Supervisor, DpExchange.Core.Venue, or any other), child_spec/1, child_spec/2 and start_link/1 on every module regardless of declared behaviour, and every compiler-injected export. A default-argument function (def f(a, b \\ x), which compiles to both f/1 and f/2) is treated as one unit named at its highest arity, wired the moment either arity has a caller from outside the pair — found necessary by running this check against real code: dp_exchange_schwab's pre-fix Feed.subscribe/2 and Auth.headers/1 were each the unused lower-arity half of a function whose higher arity every real caller already used explicitly, and reporting each arity independently would have flagged both as noise.

    Verified against the real defect: reconstructing dp_exchange_schwab at the commit before both fixes landed (c2f19b9, parent of 09b8d1f and bf2e241), the check flags Auth.refresh/2, Auth.needs_refresh?/2 and Feed.subscribe_notices/2 by name, with file and line — the exact mechanisms issue #16/#22's family and the Schwab incidents left unwired. Run against all five venue packages as they stand today, every one currently has at least one real finding — mostly def-exposed getters over a module attribute that production code reads directly instead (harmless but genuinely dead), plus a few worth a closer look: dp_exchange_webull's MqttPacket.disconnect/0 and MqttPacket.subscribe/2, and dp_exchange_schwab's Auth.needs_refresh?/2 and StreamerProtocol.logout/2 — needs_refresh?/2 remains unwired even after bf2e241, which wired refresh/2 but not the function that was supposed to decide when to call it. Fixes are tracked separately, per venue.

    Documented in usage-rules/adapter.md next to the rate_limit_blocking section it follows the same shape as.

  • PollingFeed gains :on_notice — a feed that knows it has delivered nothing now says so on a channel a consumer can act on, not only in a log line, per DpCryptoManagement's issue #21. PollingFeed already detected this condition and named it in its own words — Logger.warning("... has delivered NOTHING in 154 consecutive attempts ...") — and stopped there. Issue #21 was found only because a human went grepping logs for that literal sentence; issue #22 took days for the same reason on a different venue. A Logger.warning is not a signal a supervising process can subscribe to.

    :on_notice is an injected function, the same shape :on_refusal already is, called with a %Core.Notice{kind: :coverage_change} the instant the feed crosses INTO the delivering-nothing state, and a severity: :info recovery notice the instant it crosses back OUT — a consumer that learns a feed died and never learns it recovered is only half-served. :coverage_change was chosen over inventing a new kind: it is the same kind dp_exchange_coinbase uses for the sibling case (a channel subscribe that exhausted its retries without ever becoming delivery), and "subscribed intent not becoming delivery" is exactly what a feed delivering nothing is. It fires once per transition, never once per failed tick and never once per sweep while an outage continues — the existing "delivered NOTHING" log line still repeats every sweep by design, so a consumer wanting only that repetition still has it; the notice channel is additive, not a replacement. details carries the feed's label, the consecutive failure count, and the last error — never a credential or a raw payload; Core.Notice.new/3 refuses credential-shaped keys outright and would raise if it carried one.

    Defaults to a no-op, so every existing caller of PollingFeed.start_link/1 is unaffected. Wiring Robinhood's and Schwab's own feeds to fan this out to their subscribe_notices/1 subscribers is a follow-up once this ships — Core has to publish first, since both packages depend on it from Hex.

  • coverage_by_kind/1 — the Core half of splitting coverage/1 by data kind, per DpCryptoManagement's issue #22. coverage/1 is correct and unchanged: it counts any payload for a symbol as delivering, a Types.OrderBook exactly as much as a Types.Quote. That is why Coinbase's level2 channel delivering over 11,000 frames for 406 symbols while ticker was dark for all but 5 still reported coverage/1 as :stream for all 406 — truthfully, and uselessly, because "one kind dark, another healthy" and "everything healthy" produce the identical map. Verified by running it: coverage after ONLY an OrderBook (no ticker quote): %{"XLM-USD" => :stream}. See docs/design/2026-09-05_coverage-by-data-kind.md for the fuller account, including why the consumer's own two proposed fixes (:subscribed_pending, a delivering/1 companion) would not have caught this: both split subscribed from delivering, and this defect was never about that axis.

    @callback coverage_by_kind(keyword()) :: %{Capabilities.data_kind() => %{symbol() => route()}} reuses the existing data_kind() vocabulary rather than inventing a parallel one, and is added to Venue.@optional_callbacks required to be optional: a venue package depends on Core from Hex, so a required callback here would mean every venue instantly failing completeness the moment this version publishes — the exact cross-repo coupling that caused a premature-deploy incident once already and delayed the :gfw/:gfm wiring behind a Core release before that. required_callbacks/0 is unchanged; peripheral_endpoints/0 classifies it irreplaceable and not load-bearing.

    AdapterContract gains assertion group 15, asserted only when a venue exports the callback (Code.ensure_loaded?/1 then function_exported?/3 — the former is what stops the latter spuriously reporting false for a merely-unloaded module): the union of symbols across every kind must equal coverage/1's own key set exactly, and every kind key must be one the same venue's own capabilities().streamable declares. An absent callback asserts nothing — a venue that has not adopted yet is not a failure, and the moduledoc says so in the if guard's own comment so nobody "fixes" it into a hard requirement later. ReferenceVenue deliberately does not implement it, so Core's own conformance run (AdapterContractTest) is the regression proof that the suite stays green against a non-adopting venue; three fixtures in contract_teeth_test.exs replicate the assertion's exact computation against a conforming fake and two deliberately broken ones (a union that drops a symbol, a kind not declared in streamable), the same pattern assertions 1, 4 and 12 already use in that file.

    The moduledoc's own group count was wrong before this landed — it said "Thirteen groups" while assertions/0 already listed fourteen, a drift caught while adding the fifteenth. Corrected alongside every other place in this repo that names a callback or assertion count (README.md, usage-rules.md, usage-rules/adapter.md, usage-rules/testing.md, usage-rules/feeds.md, docs/guides/building-an-exchange-package.md) — 87 callbacks became 88, fourteen assertion groups became fifteen.

    usage-rules/feeds.md and usage-rules.md both document the failure this callback exists to make visible, not only the callback's shape — a consuming agent reading either now learns that coverage/1 alone cannot distinguish a half-dead feed from a healthy one, which is the whole reason this shipped.

    Venue adoption (Coinbase, Gemini, Webull, Schwab, Robinhood) is tracked separately in the design doc's checklist and is not part of this change — Core ships first, by design.

  • Types.OrderBookDelta — the Core half of "packages pass streamed data on; they do not maintain books", per docs/design/2026-09-06_stop-maintaining-books-in-packages.md. Types.OrderBook is a full, sorted snapshot and Core had no incremental type at all, so a venue streaming deltas had exactly one option: fold every one into a book it held itself and hand the whole thing back. dp_exchange_coinbase's Socket did this — a full book per symbol, measured at ~22,800 bid and ~21,100 ask levels for BTC-USD on a consumer's live node, rebuilt on every l2_data frame, inside a socket process that was starving its own :send_timeout because it was never idle. That was market state duplicated in the one place that could least afford it, while the host receiving it was already streaming the same data into its own store.

    OrderBookDelta carries symbol, levels, the venue's own timestamp, its sequence where it publishes one (nil where it does not, exactly as OrderBook's does) and provider, with a validating new/1 built on Types.Validate like every other type in the directory. levels is [{side, price, quantity}] — OrderBook.level/0's {price, quantity} pair with the changed side prepended — kept as one flat list in the venue's own order rather than split into per-side lists, because a single delta frame changes both sides in one venue-ordered message and splitting it would either drop that order or invent one never sent. A quantity of zero means the level ceased to exist, not a price of zero — carried through unchanged, never resolved here, exactly the meaning already documented at Coinbase's own apply_book_row/2.

    This does not reintroduce the incident that made Socket build a book in the first place — a caller reading one l2_data delta as though it were the whole book "would see a handful of prices and nothing else." The fix is the distinct type, not accumulated state: a caller cannot mistake an %OrderBookDelta{} for an %OrderBook{}, because the struct name says which one it is holding.

    :order_book stays the right data_kind() for a delta stream — no new kind was added. coverage_by_kind/1 answers "which kind of data is arriving", not "in what shape"; a host asking whether book data is arriving does not care whether the next message is a snapshot or a delta, and the struct type itself is what already tells a caller which shape it holds. Adding a kind is not free — it is a closed vocabulary every venue declares against — and this distinction was never what coverage_by_kind/1 was built to make.

    Reconnect reconciliation is now the host's job, documented rather than left inferred (usage-rules/feeds.md, new "An order book stream delivers deltas, not a maintained book" section): a package holding no book has nothing to wipe on reconnect, so the fact that deltas after one are not contiguous with deltas before it is now visible instead of silently absorbed. The existing :link_down/:link_up notices bracket where the gap falls, and :sequence on both types lets a host confirm contiguity — consistent with this family's existing rule that a notice is a prompt to re-read, never the record: the correct response to :link_up is to re-pull get_order_book/2 and resume from there, not to keep applying deltas across a gap nothing can fill back in.

    This is additive to Core — nothing existing changes shape. It exists to enable a breaking change in dp_exchange_coinbase, tracked separately: that package will stop building and delivering a full OrderBook per delta and start passing OrderBookDelta straight through, once it depends on this version.

Fixed

  • Six false claims in shipped documentation, corrected against the code. Nothing tests prose, and all six were the same shape: a statement about the family that was true when it was written and rotted silently.

    • usage-rules/testing.md and docs/guides/building-an-exchange-package.md both said the conformance suite has fifteen assertion groups. It has had sixteen since assertion 16 ("internal wiring") landed, as assertions/0 and AdapterContract's own moduledoc already said. The identical drift is recorded once before, at fourteen.
    • usage-rules/feeds.md's per-venue table had three of five streamable rows wrong: Coinbase is [:quotes, :order_book] (not [:quotes]), Webull is [:quotes, :top_of_book, :trades] (not [:quotes]), and Robinhood is [:top_of_book] — deliberately not [:quotes], because that venue publishes no last-trade data to poll for.
    • usage-rules/money-movement.md showed Coinbase as a blank row. transfer_internal/4 is live there, and so are list_payment_methods/2 and get_payment_method/3; only withdrawal and everything around it is :unsupported. "Gemini is the only venue that moves money through its API" is now stated as what is actually true — the only one whose API moves funds off the venue.
    • Capabilities' supports_order_preview comment said only Schwab declares it. Coinbase and Webull declare it too.
    • docs/guides/building-an-exchange-package.md said no venue checked so far has a working sandbox. Gemini's does, and usage-rules/environments.md has said so, measured, since 2026-08-28.
    • docs/reference/core/negative-claims.md said Core makes no claim about what a venue serves. Four of its shipped tables do exactly that, unchecked by any test; the audit section now names them as the place a venue fact goes wrong in Core.
  • The nil-vs-absent Keyword.get trap, closed as a class rather than one incident at a time (C1). polling_feed.ex's :start_delay_ms already carried a fix and an incident comment; the same trap was open at every other default-bearing option in PollingFeed, HttpClient and DefaultRateLimiter — reachable because every venue forwards its own opts unchanged by family convention, so a key the caller never set arrives as key: nil rather than absent, and Keyword.get(opts, key, default) only substitutes default for an ABSENT key. interval_ms: nil crashed Process.send_after/3 and restarted the feed straight into the same crash; on_refusal: nil raised BadFunctionError; symbols: nil raised inside MapSet.new/1. HttpClient's retry_attempts: nil was worst: Erlang term ordering sorts nil above every integer, so nil > 1 is true, and a forwarded nil silently entered the retry branch and died computing 4 - nil — an ArithmeticError raised directly in the calling venue process, which this library does not supervise. Fixed with one shared helper, DpExchange.Core.Config.opt/3, applied at every reachable site across the three modules (not only the four originally named) — a present-and-nil value is now treated the same as an absent one everywhere a default applies, and an explicit false is still honoured, because opt/3 deliberately does not use ||.

  • PollingFeed — a hung fetch wedged the entire feed, silently (C2). fetch/fetch_all ran synchronously inside handle_info with no timeout boundary; safely/1 caught a raise or an exit, not a call that simply never returns. Verified with a fetcher doing Process.sleep(:infinity): status/1 and coverage/1 never answered, every symbol went dark, and nothing was logged — which defeats this module's own headline design, since its moduledoc exists specifically to make a silently-broken feed loud. Every fetch now runs inside a bounded, disposable Task (bounded_fetch/2, Task.async + Task.yield + Task.shutdown), and a hang past :fetch_timeout_ms becomes an ordinary fetch failure — retried next tick, counted toward failures_since_ok, escalated by the existing "delivered NOTHING" warning. The default timeout is derived from the poll interval and clamped between 30s and 60s: a floor above HttpClient's own 30s per-request default, so a short interval cannot self-sabotage an entirely ordinary retrying HTTP call, and a ceiling so a venue polled once an hour cannot wedge this feed for an hour.

  • DefaultRateLimiter — timeout: nil silently disabled the wait ceiling (C3). acquire/3 read :timeout with a plain Keyword.get/3, so a forwarded timeout: nil — reachable from HttpClient, whose limiter_opts/1 forwards :timeout verbatim — produced wait_ms > nil, which Erlang term ordering makes always false. "Fail closed after N ms" silently became "wait however long it takes". Verified live against an exhausted bucket. Covered by the same DpExchange.Core.Config.opt/3 fix as C1, and asserted with its own regression test: an exhausted bucket with timeout: nil now refuses near-instantly (the refusal is decided on the server, before any sleep) rather than sleeping out a near-minute wait in the caller.

  • HttpClient under-recorded real venue usage (C4). record/3 — the call that fills the bucket acquire/3 and check/3 measure against — was only reached from the {:ok, response} branch of the request pipeline. A retried 5xx and a venue 429 both genuinely reached the wire and genuinely consumed the venue's quota, and neither was recorded — the same mechanism as the incident already recorded in this module's own moduledoc ("395 calls per 60s against a documented 300, while the budget panel read 83/240"): the missing calls there were exactly the retried and rate-limited ones this closes. Every outcome of a request that actually reaches make_http_request/5 — success, retry, 429, or a permanent 4xx — is now recorded exactly once, right after the request is made and before the result is inspected; a request refused by the limiter itself, before anything left the process, is still not recorded.

  • Types.* — @enforce_keys guarded presence, not nil (C5). %Candle{open: nil, high: ..., low: ..., close: ..., ...} built without complaint despite open's typespec declaring Decimal.t(), never Decimal.t() | nil — exactly what a JSON decode bug on a renamed venue key produces, and the failure only surfaced later, deep inside Decimal, far from where the bad data entered. Every Types.* module now exposes a validating new/1, built on a new shared helper, DpExchange.Core.Types.Validate, that checks every field named in the module's own @enforce_keys for nil as well as presence and raises ArgumentError naming the offending field. Types.Order is the one deliberate exception: its own moduledoc documents that six of its seven enforced keys legitimately admit nil ("the venue's word, or nothing"), so its new/1 narrows the check to :provider alone, the one field that was never meant to be nil. Struct literals (%Candle{...}) are unchanged and remain valid for internal and test use; new/1 is the path a venue's own decoder should prefer.

  • CanonicalPair trusted caller-supplied quote ordering (C6). The moduledoc requires a venue's quotes list to be given longest-first; nothing enforced it, and the module's own round-trip invariant does not catch a misordering — concatenation round-trips byte-for-byte regardless of where the cut landed. Verified: quotes: ["USD", "BUSD"] mis-split "ETHBUSD" into "ETHB-USD". quotes is now sorted by length, descending, inside CanonicalPair itself before any suffix match is attempted, so a caller cannot get the ordering wrong any more, whatever order it hands in.

Added

  • time_in_force vocabulary extended with :gfw and :gfm — "good for week" and "good for month" (C7). Real Robinhood values, confirmed in the vendor's own OpenAPI schema (both the order request and response schemas, enum ["gtc","gfd","gfw","gfm"]), with no slot in this contract's vocabulary before now. Purely additive: existing venues declaring a subset of supported_time_in_force are unaffected. Robinhood could not use the new values until this shipped to Hex, so wiring Robinhood.to_order/1 and order_config/2 was sequenced as a follow-up rather than done in the same batch — the cross-repo atom coupling is what caused a prior premature-deploy incident. That follow-up has since landed: dp_exchange_core 0.1.45 published these atoms, and dp_exchange_robinhood now decodes all four vendor values and raised its dependency floor to ~> 0.1.45 so it cannot compile against a Core lacking them.

  • DpExchange.Core.FakeInjection — deterministic failure injection and a credential-free wiring mode for a venue's Fake — DpCryptoManagement's issue #14. None of the four venue Fakes exposed a configure/1-shaped seam for exercising a consumer's own retry/circuit-breaker code, or a way to skip a Fake's venue-faithful credential check to test pure dispatch/decode logic. Built on Core.Config's existing process-scoped override machinery rather than a new mechanism — the exact async: true isolation guarantee every other seam in this family already has.

    Deterministic by design: outcomes are queued explicitly and popped in order, never a probability. Per-symbol targeting composes with whole-call injection — a symbol-specific queue is checked first, and a symbol-targeted failure can never affect a different symbol's call, matching this family's established rule that one bad symbol must not fail a whole batch. Function-level targeting was deliberately left out: the feature this replaces asked for one global knob, and no filed need asked for more.

    This ships the shared mechanism only; the four Fakes adopt it one at a time in their own packages, starting with Robinhood. See docs/design/2026-09-04_webull-sharding-and-fake-injection.md §3.6/§3.7.

  • The conformance suite now asserts coverage rather than accepting it as a claim (O4). Three new assertions, and the one worth naming exists because the drift it hunts had just happened: a venue package declared six streamable kinds while its socket was written, tested and never called by the facade. Four of the six reached no subscriber by any route, and every test passed for a release — the socket's own tests exercise its callbacks directly, and nothing asked what a consumer receives.

    • Every absence has a recorded cause. An endpoint named in venue_does_not_serve/0 must actually be declared :unsupported. The mislabel goes both ways and both are defects: a venue's own absence filed as a backlog item invents work that cannot be done, and a backlog item filed as the venue's absence hides a capability a consumer could have had. Robinhood shipped four of the first kind and no test failed — nothing fails when a comment is wrong.
    • streamable names only kinds this contract has a word for. A structural check cannot prove delivery, but it can refuse a vocabulary the contract does not define, which is where over-declaration usually starts.
    • A streamed kind is not contradicted by its own package. A kind declared streamable while the same package's venue_does_not_serve/0 says the venue has no such data at all is a contradiction that cannot be true in either direction.

    All five venue packages pass the three today; they were run against each before this landed.

Fixed

  • Notice.reject_credentials!/1 could exhaust the VM's atom table from venue-derived input (C8). It normalised every details key with String.to_atom/1 before comparing it against the credential vocabulary. Atoms are never garbage collected and the atom table is finite; details maps are built by venue packages from venue-supplied content (a channel name, a raw payload key, a symbol) with nothing in the contract bounding their keys, so a venue varying that content could walk the table to exhaustion and kill the whole node — through a guard whose entire purpose is to make notices safe. Fixed by deriving a string set from @credential_keys once, at compile time, and comparing every incoming key as a downcased string; no atom is ever created from caller input. Same DOS.BinToAtom class Core.FakeInjection was already built to avoid. The raised error still names the offending keys exactly as before.

  • PollingFeed crashed when a caller forwarded start_delay_ms: nil. Robinhood's and Schwab's own Feed wrappers both build this option with Keyword.get(opts, :start_delay_ms) and no default of their own — a present key with a nil value whenever their caller never set one. Keyword.get/3's own default only substitutes for an ABSENT key, not a present-and-nil one, so state.start_delay_ms ended up nil and crashed in Process.send_after/3. Fixed at this layer with || @default, so every venue's Feed is covered rather than each patching its own pass-through.

  • A stray zero-byte lib/dp_exchange/x.new was shipping in the tarball. It arrived as a redirect artefact in cf03c21 and had been published in every release since. Found by doing what mix.exs's own comment block says to do — inspecting mix hex.build output before publishing — which is the same check that caught the 4.4 MB PLT. Nothing warns about either; the only defence is reading the file list.

Documentation

  • Three new guides, and the first is the one this plan most needed. usage-rules/auth.md states the split once, plainly — storage is the host's, use is the package's — and then does the thing nothing in the family did: a per-venue table. Schwab is three-legged OAuth with a one-time-use refresh token on a seven-day sliding window; Gemini is HMAC or OAuth, sharing a refresh URL with the host's own code exchange and separated only by grant_type; Coinbase and Robinhood are Ed25519; Webull has two token systems, one of which returns 200 with a token that does not work until a person enters an SMS code.

    A host integrating two venues implements two different things, and until now nothing said so. It also carries the restart-versus-refresh decision table: a host that does not know that distinction loses sessions silently and has no operator action available.

  • usage-rules/money-movement.md — the group where a defect moves funds, and the only one that can never be tested here. Preconditions in order, with the reason each is not style advice: the network is required and never defaulted because funds sent to a chain the venue does not credit are gone; memo_required: nil means the venue did not say, not that no memo is needed; a retry without an idempotency key withdraws twice, which is why this family always sends one rather than waiting to be asked.

  • usage-rules/environments.md — running live and demo in one supervision tree, resolved per process rather than per node. Records what each venue actually offers: Gemini's demo is a full exchange with test funds, Webull's UAT has REST and no broker at all, and the other three have nothing.

  • The four existing guides are rewritten around the surface that shipped. feeds.md now covers four pushing venues and one polling behind the same facade; symbols.md covers venues whose symbol is not a pair, where the work is refusal rather than transformation; testing.md states which of the four tiers each capability group can actually reach, and which cannot be reached at all; adapter.md covers the options surface, the two-list split for absences, and the negative-claim audit as a required artefact.

  • docs/reference/core/negative-claims.md — Core's negatives are about the contract and the ecosystem rather than a venue, and they are audited the same way. Every one holds. The packaging claim needed correcting: 7.5 MB of saved Schwab portal HTML sat in docs/guides/, which is in files:, and would have published inside a package whose whole premise is that it ships nothing venue-specific.

  • README.md states what the contract covers — 87 callbacks by group — and indexes the seven guides. AGENTS.md points at them.

Added

  • place_orders/3 — several orders in one request, which closes OQ8.

    It is not place_order/3 in a loop. A batch is one request the venue accepts or rejects as a unit; N calls are N partial outcomes a caller has to reconcile, and the reconciliation is exactly what goes wrong when the third of five fails. A venue that publishes a batch endpoint gives a consumer an atomicity it cannot build from the single-order call, which is why this is a callback rather than a helper a consumer writes.

    A partial batch is the shape to expect, not the exception. Venues validate per order and return per order, so the result is a list the same length as the request — each entry either an order or the venue's refusal of that one. Collapsing it into a single ok-or-error is the failure this callback is documented against: a caller told "the batch failed" when four of five were placed has four positions it does not know about.

    Venues cap the size — Webull at 50 — and a request over the cap is refused by the venue rather than split by a package. Splitting turns one atomic request into several and quietly undoes the only reason to call it.

Changed

  • Types.Order's :symbol and :id now admit nil, joining the four that already did.

    Robinhood acknowledges a cancel request without describing the order it cancelled: there is an id and nothing else. Inventing a symbol to satisfy a type would put a guess where the venue was silent, which is the one thing this type's enforced-but-nullable keys exist to prevent. The keys stay enforced so a constructor must decide; the types admit nil so the decision can be "the venue did not say".

  • asset_classes/0's vocabulary widened from [:crypto, :equity] to [:crypto, :equity, :option, :future, :event_contract], and the conformance suite's known-classes assertion with it.

    The narrower list was not a decision about scope — it was the set of classes any package had reached so far, frozen into an assertion. The first package to serve option endpoints could not declare it without failing conformance, and a class a venue serves but cannot declare is a class the host cannot route to. asset_classes/0 is a statement about a package today; the contract now says so where it is declared.

Added

  • place_orders/3 — several orders in one request, which closes OQ8.

    It is not place_order/3 in a loop. A batch is one request the venue accepts or rejects as a unit; N calls are N partial outcomes a caller has to reconcile, and the reconciliation is exactly what goes wrong when the third of five fails. A venue that publishes a batch endpoint gives a consumer an atomicity it cannot build from the single-order call, which is why this is a callback rather than a helper a consumer writes.

    A partial batch is the shape to expect, not the exception. Venues validate per order and return per order, so the result is a list the same length as the request — each entry either an order or the venue's refusal of that one. Collapsing it into a single ok-or-error is the failure this callback is documented against: a caller told "the batch failed" when four of five were placed has four positions it does not know about.

    Venues cap the size — Webull at 50 — and a request over the cap is refused by the venue rather than split by a package. Splitting turns one atomic request into several and quietly undoes the only reason to call it.

  • Three more account-and-funding callbacks: get_payment_method/3, get_notional_balances/3 and list_custody_fees/2.

    get_payment_method/3 exists because a listing is a snapshot. A funding source's verification state changes without the account doing anything — a bank closes, a card expires, a venue suspends a rail. Picking the row out of an earlier list_payment_methods/2 result reads a status that may have been true an hour ago, and moving fiat against it is the failure that produces.

    get_notional_balances/3 is not get_balances/2 in another unit. The quantity is the venue's ledger; the notional figure beside it is the venue's valuation of that quantity at a rate the venue chose and does not have to publish. Two venues will disagree about the notional value of the same holding and both be right about the balance. Rows stay the venue's own maps so the two numbers cannot be read as one — the valuation is the one that is only ever an estimate. Reconcile positions with get_balances/2; this is for reporting.

    list_custody_fees/2 explains a balance reduction with no trade behind it. Custody fees are periodic and come straight out of the balance, so a consumer reconciling against fills alone finds a gap it cannot account for. An empty list means the venue charged nothing in the window asked for — it never means the venue does not charge. A venue with no custody product returns {:error, :not_supported}, which is what tells the two apart.

  • Six money-movement callbacks: list_payment_methods/2, add_payment_method/2, transfer_internal/4, request_approved_address/4, remove_approved_address/3 and get_transactions/2.

    transfer_internal/4 is not withdraw/5. Nothing leaves the venue, no chain is involved and no address is required. Conflating them is dangerous in both directions: a caller reaching for withdraw/5 for an internal move pays a network fee it did not need to, and one reaching for this expecting an external transfer sends nothing anywhere.

    request_approved_address/4 is the most consequential write in this contract — an address on the allowlist is one funds can be sent to. It requests rather than grants: venues hold new entries under a time lock, and a successful response is not permission to withdraw. Removal is separate and generally immediate, which is the asymmetry to expect — a venue is slow to widen what funds may reach and quick to narrow it.

    A payment method being listed does not mean it is usable, and a newly added one is pending: venues verify a bank account out of band and the API call only starts that. details stays the venue's own shape, because bank details differ by country and a normalised struct would be wrong for every country but one.

    get_transactions/2 is wider than both get_trade_history/2 and get_transfers/2 — fees, interest, dividends and adjustments alongside deposits and fills. Summing it is not a balance; get_balances/2 is the authority and this is the explanation.

  • list_networks/2 and list_fee_promos/1.

    list_networks/2 is what get_deposit_address/3 needs before it can be called. That callback takes a network, and nothing else in the contract said which networks a venue accepts for an asset. Guessing one produces an address on a chain the venue does not credit, and funds sent there are gone — the single most expensive mistake available in this surface. It answers both directions, because venues publish both and they are different questions: which networks carry an asset, and which assets a network carries.

    Rows stay the venue's own maps. Network naming is not standardised — one venue's ethereum is another's ERC20 — and normalising here would invent a vocabulary no venue accepts back.

    list_fee_promos/1 is not get_fees/2. That returns the schedule applying to a credential; this is a public list of symbols where the venue charges something other than its published schedule. A caller computing cost from the schedule alone is wrong for exactly the symbols on this list.

  • get_fx_rate/3 and Types.FxRate. Gemini publishes GET /v2/fxrate/{pair}/{ts} and the family had no shape for it.

    It is not a rate the venue trades at. Gemini's own documentation says it "does not offer foreign exchange services" and that the endpoint is "for historical reference only"; the number comes from a third party the venue names. So :source and :benchmark are carried alongside the rate, and :provider — the venue relaying it — is a separate field. Collapsing them would make a Gemini-relayed BCB rate indistinguishable from one Gemini computed itself, and only the second would be the venue's own claim. Two venues relaying the same pair at the same instant can legitimately disagree, and a caller reconciling them needs to know it is comparing sources rather than finding a bug.

    :as_of is the instant asked for, echoed by the venue. A rate without it is a number with no time attached, which is not a rate.

  • get_trades/2 — the public tape. Types.Trade already existed and nothing could return it; two venues publish the tape and the family had no callback for it.

    It is not get_trade_history/2, which returns the credential's own fills. The tape is everyone's executions and has no order of yours behind it — answering one with the other hands a caller a filtered view of the market and calls it the market.

  • Types.Trade gains :broken, defaulting to false. Exchanges bust erroneous prints, and a broken trade did not stand: its price is not a price the market traded at. Leaving one in a series puts a phantom high or low into every range, breakout and volatility figure built on it, and none of them will error. get_trades/2 excludes them unless opts[:include_broken] says otherwise — hiding them entirely would conceal that the exchange made a correction.

    The moduledoc now also records what :side means: venues report the taker's side, so Gemini's buy means an ask was removed by an incoming buy order. A package mapping that to "the maker was selling" inverts every entry while every number stays real.

  • get_auction_imbalance/2 and get_volume_profile/3, with Types.AuctionImbalance and Types.VolumeProfile. Two equity-microstructure capabilities Webull publishes that the family had no facade or shape for.

    An auction imbalance is not a quote or a book. During an auction the continuous book stops being the price; what matters is how much can be matched, how much cannot, and where it would clear — three numbers a Quote has nowhere to put. A caller reading a continuous quote at 15:59 is reading a book that is not where the close will happen. opts[:auction] is required, because the opening and closing auctions are different auctions with different windows.

    The imbalance side is carried as the venue sent it, unmapped. Venues publish the direction as a code and the tables differ — Webull documents imbalance_side with the example "2" and does not say what 2 means. Guessing it backwards tells a caller there is unmatched buying when there is selling: wrong, entirely plausible, and at the one moment of the day with the most volume behind it.

    A volume profile is not a candle with extra fields. A candle's single volume number cannot say that of 1,000 shares 600 lifted the ask and 400 hit the bid, nor at which prices each happened, and neither type is derivable from the other. :delta is the venue's own figure and is not recomputed from the totals: a venue that classifies some prints as neither aggressive buy nor sell reports numbers that do not reconcile, and that gap is information about its classifier rather than a fault to paper over.

    get_auction_imbalance/2 returns a list, newest first, because the venue publishes a series: the imbalance updates every few seconds through the auction window, and how it moved is the point. opts[:history] selects the published series where a venue serves the snapshot and the series separately — the same shape get_orders/2 uses for resting versus closed orders. A series entry may carry less than a snapshot: Webull's NOII bars publish the three prices and the time and not the quantities or the side, which come back nil — the venue did not publish them there, rather than the imbalance being zero.

  • :event_contract in the instrument-type vocabulary. Webull lists event contracts as a tradable instrument type and the vocabulary had no term for one, so a package serving them had to declare something untrue.

    It is not an option and not a future. There is no strike, no underlying to deliver, and the payoff is a step at 0 or 1 rather than a curve — declaring one as :option would hand a caller a Greeks-shaped hole where the instrument has no Greeks.

  • convert/4 and get_trade_volume/2 on Venue. Two more Gemini endpoints with no facade.

    convert/4 is not a shorthand for quote_conversion/4 plus commit_conversion/2, and the difference is who carries the price risk. The two-step form shows a rate and holds it: the caller sees the number before anything moves. convert/4 executes at whatever the venue's price is on arrival and the caller learns the rate from the result. A package cannot manufacture the first from the second — quoting a rate it computed itself and calling it held would be a promise the venue never made — so a venue declares each independently. Gemini's /v1/wrap/{symbol} is the one-step form.

    get_trade_volume/2 is the account's own volume, not the market's, and not get_trade_history/2 summed. The venue's aggregation is what its fee tiers are computed from; reproducing it means every fill over the reporting window — one request per symbol on a venue that requires one — and the result would still be this package's arithmetic rather than the venue's ledger. Where they disagree, the venue's decides what a caller is charged.

  • cancel_all_orders/2 on Venue. Gemini publishes two bulk cancels and the family had no facade for either.

    opts[:scope] is required and has no default. :session cancels what this credential's session opened; :account cancels everything the account has open, including orders placed by another key or by a person at the venue's own web interface. A default would make the wider, destructive reading the answer to a question nobody asked, and the narrower one would silently leave orders running. The caller states it.

    It is not get_orders/2 plus cancel_order/3 in a loop: that is N requests with N partial outcomes and cannot reach an order that appeared between the listing and the cancels.

    Returns %{cancelled: [id], rejected: [id]}. A non-empty rejected is not a failed call — the venue answered and some orders were already gone.

  • preview_replace/4 and close_position/3 on Venue. Both are Coinbase endpoints the family had no facade for, and both are the kind that cannot be assembled from the calls that already exist.

    preview_replace/4 is not preview_order/3 with an order id. The venue prices an amendment against the resting order's own state, including whatever of it has already filled. A caller who asks what a fresh order would cost is asking a different question and getting a different number. Without it the choice is committing to an irreversible amendment blind, or cancel-then-place — which reopens the window replace_order/4 exists to close.

    close_position/3 is not get_positions/1 plus place_order/3. The size a caller computes is the size as of the caller's last read; the venue's is the size now. On a position that moved in between, the caller's arithmetic leaves a residue or overshoots into a position the other way. Only the venue flattens to exactly zero, which is why it returns an Order — it is an order, placed on the caller's behalf with a side and size the caller never states.

    Both are peripheral, both record which of the two tests they fail, and every venue that does not serve them returns not_supported() as before.

Changed

  • Types.Order's side, order_type, quantity and status admit nil in the typespec. They always could in practice — a venue sending a status this package does not recognise has produced nil since the beginning — and the typespec said otherwise, which meant dialyzer accepted the wrong thing and rejected the right one.

    Coinbase's close_position/3 is where it surfaced: the venue never states the side of a closing order, and the type left no way to say so. The keys stay enforced, so a constructor must still decide; the types now allow that decision to be "the venue did not say".

  • BREAKING: Core.Types.Quote no longer carries :bid and :ask. They are order book data — resting orders — and Quote is trade data. Every venue package in the family was filling them, and one read price || ask from a best-bid/ask endpoint, producing a quote whose price was a resting order. Every value was real; only the meaning was wrong.

    A caller wanting the top of the book calls get_top_of_book/2. A caller wanting what traded calls get_price/2. Neither can stand in for the other.

  • Core.Types.Quote's :timestamp guarantee is unchanged and now load-bearing: the venue's own, used as-is. Observation time lives on TopOfBook.observed_at, in a field that says what it is.

Added

  • Options. Types.OptionContract (identity only — no prices), Types.OptionGreeks (model output, with the theoretical value named :model_price because it is the field most easily mistaken for a price), Types.OptionChain (two-dimensional, expiry → strike → {call, put}, a one-sided strike keeping nil rather than a missing key), and Types.OrderLeg. Callbacks get_option_chain/2, get_option_expirations/2, get_option_greeks/2.

    A chain row carrying bid, ask, last, mark and theoretical value offers five plausible prices and no help choosing, so it is split three ways: identity here, book on TopOfBook, last trade on Quote. :multiplier of nil does not mean 100. A venue that cannot trade multi-leg must refuse, never decompose — a caller left holding one filled leg has naked risk it never chose.

  • BREAKING: get_historical_prices/4 returns [Types.Candle.t()], not [Types.Quote.t()]. It declared quotes, and the venue packages returned bare untyped maps with their own key sets — so the declared type was false and nothing compared one venue's candles to another's.

    Types.Candle names its time field :opened_at, because venues disagree about whether a bar is stamped at its open or its close and the difference is one whole interval — a series joined across both conventions is misaligned by a day with every value correct. coherent?/1 catches a malformed bar at the boundary. :volume is nil when unpublished, never 0.

  • Types.Order gains :time_in_force and :legs. Capabilities.supported_time_in_force declared what a venue accepts while the order type had no field for it, so a caller reading an order back could not tell an IOC that expired from a GTC still working.

  • Derivatives. Types.Funding (settled :amount kept apart from :estimated_amount — a real response has them 40% apart) and Types.ContractStats (mark and index are separate prices, and neither is a traded price), with get_funding/2 and get_contract_stats/2.

  • Conversions. Types.Conversion plus quote_conversion/4, commit_conversion/2 and get_conversion/2 — the facade's only two-step write. :expires_at is the point: committing an expired quote can fill at the current rate, which looks like success. expired?/2 returns nil when no expiry was stated — unknown, not valid.

  • Portfolios. Types.Portfolio and list_portfolios/1. A portfolio is an address, not a value; balances, orders and positions are addressed with portfolio: id in opts rather than by adding a parameter to forty signatures.

  • Money movement, write side. Types.DepositAddress, Types.ApprovedAddress, Types.Withdrawal, and get_deposit_address/3, list_approved_addresses/1, estimate_withdrawal_fee/4, withdraw/5.

    withdraw/5 is the only operation in this contract that cannot be undone. The allow-list is first-class: ApprovedAddress.usable?/2 returns nil for a pending address with no stated activation, because venues delay first use precisely so a stolen account cannot add an address and drain it. DepositAddress.memo_required is tri-state — a deposit missing a required memo is credited to nobody, so nil must never be defaulted to false. :network is enforced on both.

  • Core.Types.Position and get_positions/1 — exposure, distinct from a balance and not derivable from one. :side is explicit and :quantity always positive, because venues disagree about how to say "short" and a guessed sign convention yields a position that is exactly backwards while every number stays plausible. Realised and unrealised P&L are separate and never summed. :liquidation_price of nil means the venue did not say, not that the position is safe.

  • data_kind gains :top_of_book, :candles and :positions. Measured against Gemini's AsyncAPI and Schwab's Streamer service list: all three are streamed by a venue in the family and had no kind. :top_of_book is deliberately not :order_book — venues stream them on separate channels because one carries a level and the other a book. t:data_kind/0 records the full channel-to-kind mapping so it can be checked rather than trusted.

  • Custodial staking. Six callbacks — get_staking_rates/1, get_staking_balances/1, get_staking_rewards/1, get_staking_history/1, stake/3, unstake/3 — and a has_staking capability flag, which earlier notes recorded as shipped and which did not exist.

    Custodial only. A venue that returns an unsigned transaction for the caller to sign and broadcast is doing something else, and one venue publishes both. A caller believing it had staked when it holds an unsigned transaction nobody signed is the most expensive form of this family's recurring failure.

    Four types, shaped by the venues' published schemas:

    • Types.StakingBalance — keeps staked, available_to_trade and available_for_withdrawal apart; a real response has the whole position redeemable and none of it tradable. by_provider is carried, not summed: a redemption is addressed to a provider.
    • Types.StakingRate — percentages only, rate_pct and apy_pct both named. One venue publishes basis points, a simple percentage and an APY for the same position; bps_to_pct/1 lives here so the 100× conversion is done once.
    • Types.StakingReward — carries its accrual period and the rate at accrual.
    • Types.StakingTransaction — carries the unbonding progression amount / amount_paid_so_far / amount_remaining. settled?/1 returns nil when the venue reports no progress — unknown, not complete.
  • Core.Types.TopOfBook — best bid and ask, with no price field. bid_size and ask_size are optional (nil means not published, never zero); venue_time is the venue's own or nil, since several BBO endpoints publish none; observed_at is required. mid/1, spread/1 and crossed?/1 are functions, not fields — a mid is derived, and a caller has to ask for it rather than find it sitting there looking like venue data.

  • get_top_of_book/2 on the Venue behaviour, registered in peripheral_endpoints/0.

  • Conformance assertion 14, "top of book is not a price" — asserts the returned struct is a TopOfBook, that observed_at is set, that venue_time is the venue's or nil, and that TopOfBook has no price field and cannot grow one.

Changed

  • preview_order/3 and replace_order/4 are now Venue callbacks, and required rather than optional. §6.1's rule is that the facade is one fixed set, never extended per venue, and optionality is reserved for callbacks where requiring them would be pure ceremony. These two are not: whether a venue can preview an order, and whether it can amend one atomically, are things a consumer routes on — and replace_order/4 is a claim about risk, since its absence means cancel-then-place, which opens a window in which no order is live.

    Not a breaking change, because there is nothing to break yet. No consumer implements this behaviour outside the family, and all five venue packages were updated in the same change. A venue that serves neither returns Venue.not_supported() and declares supports_order_preview: false / supports_order_replace: false. Once the host adopts these packages, adding a required callback would be breaking and would take the 0.2.0 seed §7.2 describes — that signal is deliberately not spent here.

Added

  • Five capability fields and two facade callbacks, closing every contract gap Schwab found. Each existed because a venue could not say something true about itself.
    • ceiling gained an optional :scope (:credential | :account | :application), and :limit became non_neg_integer. Both matter: a limiter keyed by credential silently over-permits a venue that counts per account, and a registration granted zero throughput is legal and is not :unsupported — the endpoint exists and the venue serves it; that application cannot use it, and the remedies differ.

    • supported_sessions — which trading session an order may name. [] is the continuous-market case and stays the default. [:regular] alone raises: it says nothing, and a consumer would build a session selector with one option.
    • supports_order_preview, supports_order_replace, supports_multi_leg_orders — all raise if claimed while place_order/3 is :unsupported.
    • catalog_access (:enumerable | :query_only) — whether the catalogue can be listed at all. :query_only raises if get_symbols/1 is :unsupported, because "searchable only" and "not served at all" are different facts.

    • preview_order/3 and replace_order/4 as required facade callbacks. Required rather than optional: the facade is one fixed set, and optionality is for ceremony. Both are peripheral, and replace_order/4's reason states the risk — absence means cancel-then-place, which works and opens a window with no order live.
  • Four order types: :trailing_stop, :trailing_stop_limit, :market_on_close, :limit_on_close. Real types Schwab accepts that Core had no word for, so a venue serving them had to under-declare — the safe direction, and still a lie.
  • Eight instrument types: :option, :future, :future_option, :index, :mutual_fund, :bond, :forex, :cash_equivalent. [:spot, :perp] was the whole vocabulary while every venue was crypto; an option is not a spot instrument, so an equities broker declared [:spot] plus a comment saying that understated it. A declaration that needs a comment to be true is what this struct exists to prevent.
  • Two conformance assertions: the order-shape claims must match what the facade answers, and catalog_access must match how get_symbols/1 behaves without a query.

Documentation

  • usage-rules/adapter.md never mentioned DpExchange.Core.Config.opt/3, Types.<T>.new/1 or the :gfw/:gfm addition to supported_time_in_force — all three shipped in this same [Unreleased] section (C1, C5, C7 above), and a package author reading only the guide that ships in the Hex tarball would never learn any of them exist. Fixed by adding: a "domain vocabularies are closed lists" section naming the full current supported_order_types and supported_time_in_force vocabularies, including :gfw/ :gfm and why they were added; a "prefer Types.<T>.new/1" section carrying the same @enforce_keys-guards-presence-not-nil explanation the code's own moduledoc gives, plus the Types.Order exception; and a section on the forwarded-opts nil-vs-absent trap naming DpExchange.Core.Config.opt/3 as the fix, next to the existing "opts is the venue's own vocabulary" discussion it extends. Found by auditing this package's own consumer docs the same way the family-wide sweep audited the other five packages'.

  • README.md's family table said five of six packages were "not yet published." All six are live on Hex — checked against Hex's package API 2026-09-05, every one of dp_exchange_core, dp_exchange_coinbase, dp_exchange_gemini, dp_exchange_webull, dp_exchange_robinhood and dp_exchange_schwab returns 200. Corrected to "published, experimental," with a line stating that publication is not proof of maturity — read capabilities/0 for that, not this table.

  • Two stale assertion-count claims. usage-rules/testing.md said "Thirteen assertion groups"; docs/guides/building-an-exchange-package.md said "28 assertions." Neither matches DpExchange.Core.AdapterContract.assertions/0, the canonical list the suite's own moduledoc points readers to, which currently names 14 groups. Both corrected to cite that count and the function that defines it, rather than a number that drifts every time a group grows.

[0.1.11] - 2026-08-31

Fixed

  • The conformance suite refused 1w and 1M too. Capabilities.validate_history!/1 was fixed in 0.1.10 to check Timeframe.nameable/0, but AdapterContract's assertion 2 still checked known/0 — so a venue serving weekly or monthly candles built its declaration successfully and then failed Core's own conformance suite. That is the worse of the two failures: the package looks correct right up until the suite it exists to satisfy rejects it. Second site of one defect; found running the suite against Schwab.

[0.1.10] - 2026-08-31

Added

  • Timeframe.nameable/0 and Timeframe.nameable?/1 — the widths Core can read as a label, which is deliberately wider than known/0, the widths it can bucket. 1w and 1M are nameable and have no boundary rule, and never will: a weekly bar's start depends on which weekday the venue begins its week, and a month is not a fixed number of seconds.
  • max_leverage accepts :per_account — a positive statement that the venue margins and the ceiling belongs to the account rather than to the venue. Reg-T forced it: a Schwab margin account carries five different buying powers that are not multiples of one another, and a cash account at the same venue carries none of them, so no scalar is true. nil with supports_margin: true still raises, because nil means "nobody said" — and the error now names :per_account, so a venue author discovers the option instead of inventing a number. Without it the only ways to ship were to declare supports_margin: false, which is false, or to invent a multiplier.

Fixed

  • Capabilities no longer refuses a venue that serves weekly or monthly candles. validate_history!/1 checked historical_timeframes against Timeframe.known(), which is the set Core can bucket — so declaring 1w raised, even though Timeframe already documents both as deliberately unbucketable and instructs callers to read "no boundary rule" as "cannot check" rather than "invalid". Core contradicted itself: aligned?/2 tolerates an unmodelled width, boundary/2 passes it through, and Capabilities rejected it outright. A venue serving a real weekly candle had two options, under-declare or not ship. It now checks Timeframe.nameable/0; a width Core cannot name at all, such as 3m, is still refused. Found deriving Schwab's declaration.
  • Timeframe now models 10m (600 seconds). Its absence was not neutral: aligned?/2 returns true for a width it cannot model — "no rule" must not read as "invalid" — so every 10-minute candle passed the authenticity check unexamined, and boundary/2 was a no-op on it. Found deriving Schwab's declaration, where /pricehistory serves 1, 5, 10, 15 and 30-minute widths. Unlike 1w and 1M, which are deliberately absent because their boundaries are not fixed, 600 seconds is not ambiguous and there was no reason to leave it unmodelled.

[0.1.9] - 2026-08-28

Fixed

  • HttpClient.request/5's spec no longer advertises {:error, :rate_limited, retry_after: seconds}. It never returned it. Both rate-limit paths convert to a two-element error before returning, each deliberately and for a recorded reason — a venue 429 because a three-element tuple reaching a two-element case crashed 152 collector tasks in one night, and our own limiter's refusal because the two used to share wording and a self-inflicted throttle was read as a flaky venue for weeks. The spec was corrected rather than the behaviour. This is the fourth wrong-spec defect found by a venue package, and it does the same damage as the others: dialyzer reports a caller's correct handling of the advertised shape as unreachable dead code.

Added

  • HttpClient accepts raw_status: true, returning {:ok, response} for a 4xx instead of flattening status and body into a message string. The contract makes {:refused, reason} permanent and {:error, reason} possibly transient, and a venue states which in its 4xx body — Gemini names InvalidSymbol, InvalidParameterValue. Without this a venue package has to recover the distinction by string-matching, and String.contains?(message, "404") also matches a body that happens to contain "404". Opt-in, because the string form is what existing callers match on. 5xx is unaffected: a server error is not a venue's considered answer.
  • Capabilities ceilings may now carry an optional :burst — the depth a venue lets a caller run ahead of its rate before queueing. Found by the Gemini extraction: a GCRA limiter takes three parameters and this type carried two, so a venue that publishes its burst depth had nowhere to declare it and the package had to hardcode the number beside the declaration — the exact drift the struct exists to prevent. Gemini is the first venue in the family to publish one ("a burst rate of five additional requests that are queued"). Optional rather than required, because a venue that publishes no burst must not be made to invent one, and absence is distinguishable from a declared value. A present :burst must be a positive integer; zero is a limiter that never lets anything through.
  • Repo foundation: toolchain pin, .gitignore, formatter, credo, license, mix.exs, config layout, CI workflow, design-docs scaffolding.