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_historyandcancel_orderanswers are rebuilt throughOrder.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 whenplace_orderis active and nothing was examined. Public decisionsorder_value_fault/1andorder_values_examined/1, with teeth tests. Capabilities.time_in_force/0, the closed time_in_force vocabulary.- Tests:
Types.Orderlifecycle (vocabularies, time_in_force, status against filled quantity, legs); a precedence test for all four fake-injection rules;link_event/2andlink_up/1telemetry shapes; whole-tableTimeframepin; everyNoticekind's default severity and every credential word;Config.get/3anddelete_override/1;:blockingbuckets inHttpClient; a default-argument group inUnwiredCheck;Conversion.new/1status and values;Capabilities.time_in_force/0; the Venuecancel_order/3andget_historical_prices/4doc sections; Reference venue cancel-twice and replace-after-cancel. Struct-literal tests that could not fail (Order,OrderBook,Quotedefaults) now build throughnew/1.
Changed
Order.new/1refuses what it cannot vouch for.time_in_forcemust be in the vocabulary or nil;:filled/:partially_filledwith a stated filled quantity of 0,:pending/:open/:partially_filledwith the whole quantity filled, and:filledwith only part filled all raise.legsmust be nil or a list ofOrderLeg. Terminal statuses are not judged against filled quantity. BREAKING ONLY FOR A CALLER THAT PASSED ONE OF THESE TOnew/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}fromcancel_orderstays allowed. - Reference venue:
cancel_orderandreplace_orderrefuse a terminal order ({:order_not_cancellable, status}/{:order_not_replaceable, status}), andget_trade_historyreturns aFill. - Group 37: a range around the fake's own bars must be answered.
range_fault/3answers{: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 nowstart:the bar andend:one width later (candle_range_probes/2), because astart: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.concatover a call that makes text is flagged.String.*,to_string/1,inspect/1,Enum.join/2andAtom.to_string/1in an argument are text, as<>and an interpolation are. A module name or a pipe overModule.split/1is not.
Documentation
Venue.get_historical_pricesdocuments the unsupported-width tag;Venue.cancel_orderdocuments that a terminal order stays terminal.usage-rules/testing.md,usage-rules/adapter.mdand the building guide state 54 groups, the group 21 / 5 / 54 requirements and theOrder.newrefusals. Thenew/1doc of everyTypes.*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),OrderBooksays a zero-size level is dropped by the decoder and refused bynew/1, and a stale comment inLinkSafetyCheckis corrected.
[0.3.119] - 2026-10-21
Fixed
- Conformance group 53 failed every venue on its tree-name derivation.
Module.concat/1,2was flagged for any argument that was not a literal, so each venue supervisor'sModule.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.concatis now flagged only where an argument is visibly built from text: an interpolation, a<>, aterm["key"]read or aMap.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/3has always said "Quantities and prices accept aDecimal, 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'splace_order/3each of a float, zero, a negative, NaN and infinity as the:quantityand then the:priceof 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 withorder_args:). The cases areAdapterContract.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 staticconfig/*.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,2of anything but literals) is an atom-table leak (atoms are never collected);:erlang.binary_to_term/1,2without[:safe]builds atoms and funs from bytes;Application.compile_env/3andcompile_env!/2freeze a consumer's configuration at dependency-compile time; andSystem.get_env/0,1,2andSystem.fetch_env/1,fetch_env!/1outsideconfig/runtime.exsread 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 ornil, for a sequence number) andTypes.Validate.timeframe!/2(a label inTimeframe.nameable/0).
Changed
- Conformance group 26 ends its add/remove cycle by unsubscribing the last symbol and requires
coverage/1to 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/1refuses 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:timeframeoutsideTimeframe.nameable/0:Timeframe.aligned?/2answerstruefor 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/1refuses an unnameable:timeframethe same way.Types.Fill.new/1refuses a:priceof zero or less, asTypes.Tradedoes (a negative:feestays legal: a maker rebate).Types.Order.new/1refuses a negative:quantityor:filled_quantity(zero andnilstay legal; prices are not held to a sign there).Types.Conversion.new/1refuses a:rateof zero or less and a negative:from_amountor:to_amount.Types.OrderBook.new/1andTypes.OrderBookDelta.new/1refuse a:sequencethat is neither an integer nornil:"7" > 6is true in Erlang term order, so a consumer dropping frames whose sequence is<=the last applied would apply a stale one.Instrument.new/1refuses a:baseor:quotethat is present and not a non-blank string.StakingRate.bps_to_pct/1read an integer or a float straight into aDecimal:10 ** 5000came back as aDecimalwith exponent 4_998, a value everyTypes.*constructor refuses, while the same number as text answerednil. An integer past the 34 digits a string may carry isnilnow, and both go through the exponent bound theDecimalclause applies.CanonicalPairrefuses an:asset_aliasesthat is not a map (or list of pairs) of binary => binary, naming the mapping, in both directions. An atom, anilor a number on either side raisedFunctionClauseErrorfromString.upcase/1, a non-collection raisedProtocol.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/5refuses a:provideror:rate_limit_keythat is not an atom or a non-blank string (nilstays "none given"), withArgumentErrorbefore anything is sent, like its neighbouring options. Both are printed into log lines and bucket names, and a map, tuple or pid raisedProtocol.UndefinedErrorin the calling venue process on the first retry.PollingFeedrefuses a:labelor:providerit cannot print or thatNotice.new/3would 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 runtimeupdate_symbols/2scope. 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
PollingFeedleft 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 aGenServer.stop/1or a supervisor's shutdown sent the task a:normalsignal it ignored: a hung fetch (the case:fetch_timeout_msexists for) outlived its owner, holding its socket.terminate/2now shuts the task down and cancels its timer.FakeInjectionstranded 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.ReferenceVenuecompared an order or conversion amount before checking its exponent, so a caller's1e999999999madeDecimal.gt?/2allocate a number of that many digits in the venue's process before the refusal. An amount whose exponent is pastTypes.Validate's bound is now{:invalid_quantity | :invalid_price | :invalid_amount, value}unread, as NaN is.
Tests
- A
DateTimeis compared withDateTime.compare/2rather than==where a test asserts an instant (types_test,validate_test,timeframe_test,fail_closed_edges_test,venue_test,http_client_test). - Every
assert_receiveintelemetry_testcarries a 5 s ceiling, and the twonil_recipientand lower-case delivery quiet windows guarding a negative are 500 ms, not 100. - The negative-only scans in
credential_redaction_check_testandlink_safety_check_testnow 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_testcase that assertedEnum.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
PollingFeedin bulk mode (:fetch_all) ran its clock on a bare:poll_allmessage, so a duplicate or hand-sent tick enqueued a fetch and itsreschedule/2armed 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_allis ignored.DefaultRateLimiter.penalize/3took any non-negative integer, so a hold past 2^32 - 1 ms (the longestacquire/3can ever sleep, and its answer is:rate_limit_timeoutfor any longer wait even undertimeout: :infinity) closed a bucket for as long as the limiter ran. It raisesArgumentErrorabove that bound now.HttpClient's:max_retry_after_sis 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 venueRetry-Afterthat reached it.HttpClientopened its[:dp_exchange, :request, :start]span before it read the request's header names for the credential values to scrub, and outside thetrythat closes the span: a header whose name is not text (a tuple has noString.Chars) raised from there, leaving a:startwith no:stopor: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.StakingRewardbuilt with a:period_startor:period_endthat was aDate, a string or an epoch integer, typedDateTime.t() | nil.Validate.new!/3cannot hold them by name (a filing's same-named fields areDates), so the newValidate.datetimes!/2does it for this type.Types.Conversion.expired?/2read the instant of:expires_atitself 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 thatCore.Noticerefuses 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 ofNotice's: a struct holding an:access_keyprinted it in cleartext and passed. The list is 37 names now, and a test holds everyNoticekey 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}fromcandle_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 acceptednil. No venue's group-42 request carries one today. - Conformance groups 1 (required callbacks), 15 (
coverage_by_kind/1agrees withstreamable) 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/1andcoverage_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 whatcredential_gate_fault/1answers. 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; theHttpClientoption paragraph omits:decode_bodyas a flag and the write-retry default (:retry_attemptsis 1 for a write, 3 for a read).usage-rules/feeds.md: the timer bound onPollingFeed'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.PollingFeedmoduledoc: 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
PollingFeedtests guarded a negative with a zero-waitrefute_receivedon a message sent by a fetch task, which no reply of the feed orders; they wait a real 500 ms now. PollingFeedmoduledoc: no callback (:sink,:on_refusal,:on_notice) is invoked from ahandle_call/3, so a slow one cannot time outstatus/1orcoverage/1; they run in the feed's own handlers, inline, because the feed's ordering guarantee depends on it.usage-rules/adapter.mdandusage-rules/money-movement.mdwere 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:
PurityTestreadMix.Project.config()for an:applicationkey that is never there; the package test names every guide the README links; the zero-notice wait inReferenceVenue's decisions test and twoFanoutrefutes were zero-wait guards on messages sent from other processes (500 ms now); severalTypes.*tests asserted a struct literal's own fields and build throughnew/1; theSymbolNormalizeroptional-callback test askedmodule_info/1for an attribute that is never there. The reference-venue canned answers inVenueTestare labelled as what they are. Capabilities: the:bursttypedoc said an absent burst could be told apart from "a declared burst of zero", andnew/1has 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/2raisedFunctionClauseErroron an improper list ([a: 1 | :tail]) instead of refusing it: 0.3.113's credential redaction walked the refused value withEnum.map/2. It is walked by hand now and the improper tail is kept as given.
[0.3.113] - 2026-10-20
Documentation
Noticemoduledoc: a feed restarted by its supervisor that resumed what it had accepted says so with:coverage_changeanddetails.reason: :feed_restarted(withdetails.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/2and fails if the canary is in it, or if the probe examined nothing.AdapterContract.process_credential_fault/1is public and has teeth tests against a process holding the raw map, aformat_status/1redactor and a redacting struct. The reference venue runs the group againstReferenceVenue.ProcessProbe. A venue not yet supplying a probe is unchanged; to satisfy it, seeusage-rules/testing.md. DefaultRateLimiter.child_spec/1andPollingFeed.child_spec/1take the child id from:name(PollingFeedfalls back to:label).use GenServer's spec was alwaysid: <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
HttpClienterror 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 asBearer), 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/3withraw_status: truescrubs itsHTTP <status>message the same way.Notice.new/3with non-mapdetails(the likeliest wrong shape is a keyword list holdingapi_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/2answered{:error, {:invalid_options, opts}}with the caller's wholeopts,:credentialsincluded. The tuple keeps its shape; a:credentialsentry 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 withinspect/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 throughValidate.show/1(five items, forty printable bytes, clipped to 200 characters, becauseDecimal'sInspectignores:printable_limitand 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/2metadatareasonis bounded (50 items, 1 KiB of printable text, a longer text cut with its full size marked). A raw exit reason can carry a wholeGenServercall, 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/5refuses a:getthat carries a body.mix.exsadmits 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). Arequest(: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:getis nowArgumentErrorbefore anything is sent; an empty body is sent as no body.HttpClienttreats userinfo in the URL as a credential for the plain-http refusal. Req 0.7.0 and later turnhttp://user:pass@host/xinto a BasicAuthorizationheader, and the backstop that refuses credentials over cleartext http looked only at header names, so the password went out in the clear. A remotehttp://URL with userinfo is now refused unsent ("refused: credentials are not sent over plain http"); https, loopback andplug:are unchanged.Types.VolumeProfile.point_of_control/1no longer compares a price key with an absurd exponent. The keys are the venue's own strings and are not bounded byValidate. Decimal 3.x refuses to parse"1e999999999", but Decimal 2.x (admitted bymix.exs) parses it, and at the 2.0.x floorDecimal.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 outsideValidate.max_decimal_exponent/0is unreadable, like any non-number.Types.StakingRate.bps_to_pct/1answers 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 isnilat both.AdapterContract.book_ordering_fault/2names a NaN price. It handed every price toDecimal.compare/2, which raisesDecimal.Erroron 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.mdtold 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.0and 50, with a step forscript/check_dependency_floor.sh.usage-rules.md: the consumer example bound a variable namedquote(a Kernel special form) and is renamed; theHttpClientsection 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?/1is 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
PollingFeedrefuses a durationProcess.send_after/3cannot arm.:interval_ms,:start_delay_msand:fetch_timeout_msabove 4_294_967_295 passed the integer check and raised from a timer: atinit/1for the first two, and for:fetch_timeout_mson the FIRST FETCH insidehandle_info/2, where the restart lost every runtimeupdate_symbols/2scope and raised again. All three now stop the feed at start with{:invalid_option, key, value}.HttpClient.request/5refuses a:retry_delaywhose longest retry wait no process can sleep. The wait isretry_delay * nplus a quarter, andProcess.sleep/1raises 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 anArgumentErrorbefore anything is sent, like the other retry options. One attempt never sleeps and is unaffected.DefaultRateLimiter.acquire/3answers{:error, :rate_limit_timeout}for a wait over 2^32 - 1 ms,timeout: :infinityincluded. 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/1refuses a level with a price or a size at or below zero, andTypes.OrderBookDelta.new/1a 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<= 0refuses the whole book and a zero size is dropped; only shape and order were checked.Types.*.new/1hold the value rules each type's own doc states (they were prose only; a struct literal still builds anything).Candle.new/1refuses an incoherent bar (high below low, or open or close outside[low, high]).Quote.new/1andTrade.new/1refuse a price at or below zero;TopOfBook.new/1a bid or ask at or below zero (a venue's0isnil);Position.new/1a quantity at or below zero.Fill,Balance,Conversion,DepositAddressandApprovedAddressrefuse a blank or non-text identifying field (order id, currency, asset, network, address);DepositAddress.memo_requiredistrue,falseornil.Withdrawalneeds an amount above zero, a fee not negative, and a:tx_idwhen:broadcast.StakingTransaction.amountis above zero and its progress, withStakingBalance,StakingRateandStakingRewardamounts, not negative; a reward'saccrual_countis a non-negative integer ornil.OptionContractneeds a strike and multiplier above zero,Dateexpiries and boolean-or-nilflags;OptionChainkeys its expiries onDates;OptionGreeks,ContractStats,VolumeProfileandFxRaterefuse negative open interest, volatility, volumes and non-positive rates and marks.NewsItemandWatchlistsymbolsarenilor a list of strings;FinancialStatement.line_items,CorporateEvent.detailsandScreenerResult.metricsare maps;ScreenerResult.rankis a positive integer ornil;AuctionImbalance.sideis the venue's string. New helpers inTypes.Validate:positive!/2,text!/2,maps!/2,dates!/2,symbol_lists!/2.PollingFeedreports 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'slast_error, the debug log).
Changed
Core.Venue:replace_order/4andpreview_replace/4hold a change map toplace_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,nilincluded, judged by its place_order rule), andpreview_order/3checks its request exactly asplace_order/3.{:error, {:invalid_argument, :requests}}is the tag for aplace_orders/3argument that is not a proper list. The reference venue enforces all three. Conformance group 12 now callspreview_order/3andreplace_order/4withendpoint_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 whosereplace_order/4orpreview_order/3accepts 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/0makes active.replace_order/4given an empty change map,nil, a string or a list answers{:error, {:invalid_order_request, value}}, andpreview_order/3given a requestplace_order/3refuses (a non-map, no:order_type, an unread key) answers the identical tuple. Decision:order_write_discipline_cases/2withcase_faults/2. The reference venue passes; teeth incontract_teeth_decisions_test.exs(a venue that accepts anything, one that raises). Listed inassertions/0(now fifty),usage-rules/testing.mdand theCore.Venuetext. - 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/2is:ok,coverage/1a map), so those stay unjudged. AdapterContract.child_spec_credential_fault/1, the decision of conformance group 22 (a secret passed in:credentialsmust not appear in whatchild_spec/1hands a supervisor), public so it can be shown to fail. Group 22 had no teeth test: it was an inlinerefutethat no broken fixture had ever been run through.contract_teeth_decisions_test.exsnow hands it achild_spec/1that 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/4answers{:error, {:invalid_range, range}}for a range that is not a keyword list ofDateTimes with:startbefore:end(a tuple raisedFunctionClauseErrorand a string reachedDateTime.compare/2), checked after the symbol and before the options.get_volume_profile/3answers{: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.Venuethat 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 inAdapterContract.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/4is{:error, {:invalid_amount, value}}. 48:stake/3andunstake/3answer a valid call withTypes.StakingTransaction; a refusal is not a pass. 49:get_order/3andcancel_order/3answer an id nobody placed{:refused, :not_found}. Decisions:invalid_amount_fault/2,staking_return_fault/1,unknown_id_fault/1.Core.Venuenow 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 jsonlost itsRetry-Afterand 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, unlessdecode_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 withraw_status: truean 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 asinspect/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 requiredtimeout: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 probessubscribe_notices/1only, 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/1andsubscribe/2on the Fake, giventimeout: 5_000, must answer{:error, {:unknown_option, :timeout}}. The decision isAdapterContract.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/0is the shared vocabulary, not a list every call accepts (DpExchange.Core.Optionsmoduledoc,usage-rules/adapter.md, design docdocs/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: :sandboxsent Robinhood orders to production, Schwab ignoredbase_url:with a live token, andmax_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/2isvenue.__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 nofake: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/1and conformance group 45: the documented notice shapes are now checkable. Pure, never raises, andNotice.new/3still accepts every shape (a feed must not crash while reporting). Faults:{:reason_not_matchable, v}(details.reasonpresent and neither an atom nor a tuple led by an atom: venue text belongs indetails.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 duringsubscribe_notices/1,subscribe/2andunsubscribe/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.mdsaid "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.mdandusage-rules/testing.mdsaid 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 (AdapterContractmoduledoc andusage-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/2answers:ok,{:error, {:unknown_option, key}}(a key holdingnilcounts) or{:error, {:invalid_options, opts}}for a non-keyword list;transport_options/0lists the keys every call may carry (credentials, environment, base URL, theReqtest seams, the limiter, timeout and retry knobs). A misspelledlimt: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/2andget_trades/2, called with the venue'sendpoint_optsplusdp_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 adoptsOptions.check/2.usage-rules/adapter.mdno longer says unknown options are ignored.
[0.3.100] - 2026-10-17
Changed (tick AJ)
use DpExchange.Core.AdapterContractrefuses an option it does not know (fkae:,sample_pair:) or a non-keyword list withArgumentError, 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/0lists the accepted keys.DefaultRateLimiter.start_link/1stops with{:error, {:unknown_option, key}}for any option other than:nameand:limits(limit:was ignored and the limiter ran on its default 10 requests a second).HttpClientvalidates:decode_bodyas 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) andTopOfBook(bid_size,ask_size) refuse a negative size, quantity or volume innew/1via the newTypes.Validate.non_negative!/2. Zero andnilstay legal.
[0.3.99] - 2026-10-17
Conformance: two assertions could pass without examining anything (tick AI). Group 12's
catalog_access: :query_onlyaccepted any{:error, _}fromget_symbols/1; it now needs exactly{:error, {:query_required, venue}}asusage-rules.mdstates (AdapterContract.catalog_access_fault/2, public and unit-tested). Group 38's:depthprobe treated every refusal as a pass, so a fake that refuseddepth: 1was 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.mdandusage-rules/adapter.mdsay 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:provideris always the identity; the limiter receives the key and, as:telemetry_provider, the identity, whichDefaultRateLimiteruses for its own events. Documented inusage-rules/adapter.md.A blocking wait on two buckets was counted as two rate-limit hits (tick AI). With
rate_limit_per_endpoint: trueandrate_limit_blocking: truethe pre-checkcheck/3and thenacquire/3each emitted[:dp_exchange, :rate_limit, :hit]for the same wait.DefaultRateLimiter.check/3takestelemetry: false;HttpClientasks quietly on the blocking path and reports a wait beyond the timeout once itself.A
throwout of the transport left a[:request, :start]span open (tick AI). Only raises and exits were caught, so the throw skipped:exceptionand unwound into the calling venue process. It now closes the span withkind: :throwand returns{:error, _}like an exit. Telemetry moduledoc now states the:exceptionmetadata exactly (kind,status: nil,result: :exception), the reconnect measurements and the pairing rule.HttpClientdocs now list:plug,:adapter,:req_adapter,:headers,timeout: :infinityandretry_attempts: 0.
[0.3.98] - 2026-10-16
HttpClient.request/5no longer retries a write by default (tick AH). The default of 3 attempts applied to every method, so a bare:post,:putor:deletethat 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:getand 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: everyrefute_receiveguarding 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 optionaluseoptionambiguous_place_order: fn request -> ... end, a 1-arity function that runs the venue's realplace_order/3with a transport fault after the send. Group 42 asserts the answer is{:error, {:order_unconfirmed, id, reason}}(idnil, 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/3is public and unit-tested; Core's own suite supplies a stub function. Documented in the moduledoc,usage-rules/testing.mdandusage-rules/adapter.md.
[0.3.97] - 2026-10-16
PollingFeed.update_symbols/2validates its elements (tick AG). Only the container was checked, so an improper list ([1 | 2]) raised inside the feed inMapSet.new/1and 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 raiseArgumentErrorin the caller, as:symbolsat 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) inusage-rules/feeds.mdand 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
:limitonget_historical_prices/4, and:since/:limitonget_trades/2, whichCore.Venuesays are honoured or refused, never ignored. Both now answer{:error, {:unsupported_option, key}}.get_trades/2also 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 soget/3) now refuses, withArgumentErrorbefore anything is sent, a:timeoutthat is not a non-negative integer or:infinity, a:weightthat is not a positive integer, and a:log_requests,:rate_limit_per_endpoint,:rate_limit_blockingor:raw_statusthat is not a boolean.raw_status: "false"was truthy and returned a 4xx as a success. A forwardednilis still read as absent.PollingFeedrefuses at start, as{:error, {:invalid_option, key, value}}, an:on_refusalthat is not a 2-arity function, an:on_noticethat is not 1-arity, and a:symbolsthat is not a list orMapSetof binaries (a non-collection raised insideinit/1; the callbacks were swallowed byguarded/3at first use).PollingFeedkeeps its per-symbol timer refs and cancels the pending timer of a symbol removed byupdate_symbols/2, instead of leaving it to fire and be dropped.Types.*constructors (Validate.new!/3) refuse a time field that is not aDateTimeornil:observed_at,venue_time,timestamp,as_of,active_fromand every*_at. A string, epoch integer orDatebuilt before and crashed the consumer'sDateTimearithmetic. A venue fake that builds a type with such a value will now raise.Validate.new!/3also refuses a non-nil:symbolthat is not a non-blank string and a non-nil:providerthat is neither an atom nor a string;Position.new/1refuses an:instrument_typethat is not an atom ornil. A fake buildingsymbol: ""orprovider: 42will now raise.CanonicalPair.to_canonical/2andto_exchange/2raiseArgumentErrorfor a mapping with a missing or non-binary:sep(a missing one was aKeyErrorone way and silently "no separator" the other) or a:quotesthat is not a list of binaries. An absent:quotesis read as[].PollingFeedpoll 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.mdand thePollingFeed.start_link/1doc state the new refusals.
[0.3.95] - 2026-10-15
HttpClient:max_retry_after_sis validated before anything is sent (tick AE). It was read only on a 429 and fed tomin/2, so"30"or:infinitywas silently ignored and-5became a negative wait handed to the limiter. A non-integer or negative value now raisesArgumentErrorin 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/4ask 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
nilanswer to a namedunlisted_symbol:orpadded_credentials:is now a fault, not "did not opt in".
- Docs:
AdapterContractmoduledoc andusage-rules/testing.mdlist everyuseoption (endpoint_symbols:andpackage_root:were undocumented);assertion-coverage.mdnow says 41 groups and records the "fifth axis";HttpClient's:retry_delaydocuments the backoff and exactly what is and is not retried, andrequest_error/0names 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.VenueandDpExchange.Core.Notice(andusage-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_wideand:history_truncated; a poll route's:link_downclosed by one latched:link_up; the notice kind rules (:data_qualityfor gaps and undecodable books,:refusalwithdetails.symbols,:degradedonly for a slower path,:credentials_rejectedfor a poll as well as a stream); anddetails.reasonas an atom or tagged tuple with free text indetails.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, againstcoverage/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 orMapSetholding every symbol subscribed and every symbolcoverage/1reports) and group 41, opt-in and skipped by default:unlisted_symbol:(get_price/2is{:refused, :not_listed}) andpadded_credentials:(get_balances/2is{:error, {:invalid_credentials, runtime_id}}). No existing venue run changes. ReferenceVenue(test support) answers{:refused, :not_listed}where it said{:refused, :symbol_not_listed}, exportswanted/1and refuses a padded credential.contract_teeth_test.exspinned the old tag and is updated.CanonicalPair: the upcasedasset_aliasesmap was rebuilt on everyto_canonical/2andto_exchange/2call (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'sby_symbolentry is removed when its last queued outcome is popped (it keptsymbol => []for the life of the agent); queuing no outcomes adds none.
- New optional callback
Hot-path performance (tick AD).
PollingFeed: every fetch task closed over the whole feed state (symbols,scheduled,last_ok, the job queue) becausefetch_fun/2was called inside the task'sfn, 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 ofretry_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/1now 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 insideDecimal.compare/2in the CONSUMER's process (TopOfBook.mid/1,crossed?/1,VolumeProfile.point_of_control/1). It now gets anArgumentErrorat construction naming module, field and the (truncated) value. ADecimalor an integer is accepted;nilonly where the type's typespec allows it. NewValidate.decimals!/2,decimal_map_values!/2anddecimal!/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_providervalues),StakingRate,StakingReward,StakingTransaction,Withdrawal,VolumeProfile(incl. price-map values). Also checked inside collections:OrderBookDeltalevels (price and quantity;OrderBookalready did) andOptionChainstrike keys andunderlying_price. A venue that builds with strings must parse them withDecimal.parse/1first.PollingFeed: delivering an event struct whose module atom has noElixir.prefix (an Erlang-style struct module) raisedArgumentErrorfromModule.split/1inside 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 astext/plainthat is a bare JSON scalar (null,42,"x",true) was returned as{:ok, nil}/{:ok, 42}; the same bytes asapplication/jsonwere already{:error, "Unexpected response format"}. Both are now refused, including underdecode_body: false.HttpClient.parse_rate_limit_headers/1: a negativex-ratelimit-limitorx-ratelimit-remainingwas returned as a budget; it is nownil("did not say").Types.Validate.new!/3: aDecimalwith an exponent beyond +/-1000 ("1e5000"parses in full) is refused in any field.Decimal.add/2andcompare/2align exponents with10^difference, so one such price made a consumer'sTopOfBook.mid/1,crossed?/1orVolumeProfile.point_of_control/1allocate a number of that many digits. NewValidate.max_decimal_exponent/0.StakingRate.bps_to_pct/1answersnilfor one.Timeframe.boundary/2raisedArgumentErrorfor aDateTimebefore year 0000 (it usedfrom_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 runtimeupdate_symbols/2scope); 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 withcheck/3(which reserves nothing), as the non-blocking path already did.- Docs:
DefaultRateLimiterstates 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_adapterraised 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 raisesunknown option :req_adapter, in the caller's process. It is now translated to Req's:adapter; an explicit:adapterwins.HttpClient.get/3nested 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
Authorizationwent out over http unrefused; a map is read like a list. Telemetry.endpoint/1kept the userinfo of a scheme-relative URL (//user:pass@host/x); it is stripped like any other authority's.- Internal: unreachable
catch :exitclauses inDefaultRateLimiter.record/3andpenalize/3removed (call/2already 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:
HttpClientno longer quotes a refused request into its error. Mint's{:invalid_header_value, name, value}carries the header's value (anAuthorizationor 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 theterm/valueof an exception the pipeline raised. The header name and the kind of failure remain; the content is redacted. - Security:
HttpClientrefuses 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 anhttp://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 andplug:stay allowed. - Security:
PollingFeedno longer carries what a failing fetch held intostatus/1, its logs or its notices. An exception other thanRuntimeErrortravels as its module name (KeyErrorand 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 fromGenServer.call/3quotes the call's request). - Security:
Notice.new/3refuses more credential-shapeddetailskeys (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 quotesdetails.reasonbounded. - Tests: an assertion-1 decision test borrowed a module another test file defines, and failed
with
:nofilewhenever that file had not loaded yet; it has its own module now.
[0.3.88] - 2026-10-12
- Docs:
usage-rules.mdstates thatvenue_does_not_serve/0is optional and not aVenuecallback (check it withfunction_exported?/3), documents the unconfirmed-write shapes a caller must not blindly retry (order_unconfirmed,write_unconfirmed) and the locally decidedinvalid_*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, orProcess.exit/2killed the feed. The supervisor restarted it from its start options, and every scope set byupdate_symbols/2was 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
testbodies 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 callAdapterContract.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/4andvolume_window_fault/1, andcontract_teeth_decisions_test.exshands each one the specific wrongness it exists to catch. The checks are the same ones, with two tightenings: aBalance.currencyof only whitespace is refused (assertion 24), and aBalance.providerof""is refused as well asnil. The assertion 11 test whose name claimed the facade takes no functions (it assertschild_spec/1is exported) is renamed for what it asserts. - The reference venue obeys the conventions its own suite checks.
get_historical_prices/4returned 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/2raised on a:depththat 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,DefaultRateLimiterandTelemetry. A removed-and-re-added symbol's timer test compared fetch counts overProcess.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 pollsstatus/1instead 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/3tests 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 appendeddepth: 1andlimit: 1to the venue'sendpoint_opts:forget_order_book/2; a keyword's first entry wins on lookup, so a venue declaring its owndepth: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
0is dropped and a negative size refuses the book (the docs said size<= 0was dropped in one place and a negative refused in another);usage-rules.mdsaid unrecognised options are ignored without the family-option exception;adapter.mdsaid the suite did not assert the market-data conventions (assertions 36 to 39 do, in part);testing.mdandassertion-coverage.mdgave stale group counts;TopOfBookstill describedQuote's removed:timestamp;PollingFeednamed a:not_coveredentry thatcoverage/1never contains.usage-rules.mdgained 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 andPollingFeed's per-failure debug line are lazy (Logger.debug/1with a function),HttpClientparses the URL path only whenrate_limit_per_endpointneeds it, andTelemetry.endpoint/1skips 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-booleaninclude_partialis{:invalid_option, :include_partial, v}; 37 candles are strictly oldest first and aligned, and a range keeps:startand drops:end(re-asked from the fake's own bars; a refused range is skipped); 38get_order_book/2honoursdepth: 1, andlimit: 1is accepted or refused{:unsupported_option, :limit}(an ignored:limitis 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:depthbut 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/4andCandle): 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,:depthand a booleaninclude_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/2andDpExchange.Core.Venue.subscribe/2, and in theCandle,Quote,TopOfBook,OrderBookandTrademoduledocs, plususage-rules/adapter.md. They bind REST, streams and Fakes:- BREAKING for venues: the in-progress candle is returned only with
include_partial: true(defaultfalse); Webull stock'sreal_time:becomesinclude_partial:. - BREAKING for venues:
:startis inclusive and:endexclusive onopened_at; an absent:endis now; a range one response cannot hold is walked or{:error, {:range_too_wide, _}}. opened_atis 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:
:depthis levels per side on every venue; Coinbase's:limiton 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. Asince:trade read that returns exactlylimitrows is{:error, :history_truncated}. - Book levels: unreadable level, negative size, price
<= 0or a non-list side refuses the book; a snapshot level of size<= 0is dropped. Traded prices<= 0refuse; a bid or ask of0isnil. Quote.venue_timeis the venue's time of the last trade andTopOfBook.venue_timeits time of that quote,nilwhen none is stated, never aDateheader, envelope or send time.volume_window: :printonly when every print is delivered; a conflated stream reports:running_total.- Delta streams resubscribe on a sequence gap and drop stale frames.
Trade.sideis the taker's; undocumented side codes (WebullB/S) arenilor 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.
- BREAKING for venues: the in-progress candle is returned only with
- Order-request documentation updated on
DpExchange.Core.Venue.place_order/3,DpExchange.Core.Venue.replace_order/4andDpExchange.Core.Venue.place_orders/3: an unknown request key is refused whatever its value,nilincluded (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 anil-valued key counts as absent.
Changed
DpExchange.Core.Venue.order_unconfirmed/0widened: the id isString.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_typecounts as a duration refusal. AdapterContract.nothing_checked?/3holds the "declared active but not exported" decision behind assertions 32 to 35, with teeth tests for both directions.Venue.place_order/3doc: 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
:marketorder type. With no market typemarket_price_requests/2builds no requests, andmarket_price_fault([])demanded a price refusal anyway. An empty answer list is nownil: 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/2andupdate_symbols/2a malformed symbol list (not a list, improper, a non-string or blank element) is{:error, {:invalid_symbols, value}}, never:ok. Onsubscribe/2andsubscribe_notices/1a:tothat 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 withabsent_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 onget_price/2andget_top_of_book/2is{:error, {:invalid_symbol, value}}, asked with credentials stripped. - 34.
place_order/3request discipline. A non-map is{:invalid_order_request, r}, a request with no:order_typeis{:missing_field, :order_type}, an unread key is{:unsupported_order_field, key}, and a:priceon a:marketorder is{:unexpected_field, :price}(tried with no duration and with each duration the venue'scapabilities/0names, because the duration a venue takes on a market order is its own). Skipped whenplace_order/3is:unsupported; the market check is skipped when:marketis not a supported order type. - 35. Order ids are checked first.
nilor a blank id onget_order/3andcancel_order/3is{:error, {:invalid_order_id, value}}, with credentials stripped and with and without the venue'sendpoint_opts:, so neither the account option nor credentials may be the answer. - A callback a venue declares
:unsupportedis 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.
- 32. Streaming arguments are validated first. On
Core.Venuedocuments the conventions where a venue author reads them. The callback@docs ofget_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/3now state the error shapes, the check order (arguments, then feed; request, then account option, then credentials), thatsubscribe([])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/0andorder_write_result/1(the specs ofplace_order/3andreplace_order/4now use it; the accepted answers are unchanged).- Unconfirmed order writes. An outcome of
place_order/3orreplace_order/4that 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/2and 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}).
- Unconfirmed order writes. An outcome of
Changed
- The reference venue (Core's own Fake) follows the conventions.
get_price/2,get_top_of_book/2,get_order_book/2andget_historical_prices/4answer{:error, {:invalid_symbol, v}}for anil, non-string or blank symbol (was a{:refused, :symbol_not_listed});subscribe/2,unsubscribe/2,update_symbols/2andsubscribe_notices/1refuse a malformed list or:to;get_order/3,cancel_order/3andreplace_order/4refuse a blank id;place_order/3validates 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/1no longer sends a:link_upon 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/1works on OTP before 26.2. 0.3.77 read one dictionary key withProcess.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 raisedArgumentError. On such a runtime the whole dictionary is read, as before 0.3.77. CI runs OTP 28 only, so that path is unmeasured.PollingFeedbounds a text failure reason in its:coverage_changenotice. A binary reason travelled to everyon_noticehandler 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/1reads one key from each$callersancestor, not its whole process dictionary.Process.info(pid, :dictionary)copied every entry on each lookup, andHttpClientlooks 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.:stacktraceis 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: theAuthorizationvalue or the signature.Core.HttpClientnow reduces each frame's argument list to its arity ({module, function, arity, location}). Thereasontext in that event, in"Request failed: ..."and in"Request exception: ..."is bounded (limit: 50,printable_limit: 2_048) and byte-identical toinspect/1for anything inside the bounds.HttpClient.get/3sends 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.PollingFeedqueues 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, andqueue ++ [job]copied all of them on every tick inside the process that answersstatus/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'slast_erroris 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:unsupportedendpoint 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 aCore.Types.*struct), andunserved_timeframe/1withtimeframe_fault/1(assertion 21). The "under-declaring", "string refusal", assertion 20 and assertion 21ContractTeethTestcases 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.PollingFeedno longer crashes when its failure reason holds a credential-shaped key. 0.3.7x madeNotice.new/3refuse a credential-shaped key at any depth ofdetails, and the delivering-nothing notice put the fetch's raw{:error, reason}intodetails.last_error. A decoded venue error body keyed"signature"or"token"(an ordinary 401 shape), or a{:bad_fetch_result, answer}holding one, therefore raisedArgumentErrorout ofrecord_failure/3inside the feed, which restarted from its static start opts and lost everyupdate_symbols/2scope, 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'slast_erroris unchanged and still the raw term.Core.HttpClientcatches anexitfrom 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:provideris given), reported withkind: :exit.Core.Fanout.resolve/1anddeliver/4treat a name registered to a port as nobody.Process.whereis/1answers a port for such a name andProcess.info/2raised on it inside the feed fanning out to every other subscriber.
Changed
AdapterContractassertions 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: :requiredvenue with no endpoint left to check (all exempt or declared away) passed having examined none. subscribe/2,unsubscribe/2andupdate_symbols/2are now called with a list of symbols andadd_payment_method/2with a details map. By arity alone they were handed a bare symbol string, so a fake raised inEnum.map/2orMap.merge/2and the raise was taken for an answer.
- 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
- 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) andstruct_answer?/1(assertion 5). The earlierContractTeethTestcases 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.PollingFeedemits[: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:downand its recovery (never per delivery).Core.Telemetry's moduledoc now states this, and the extra:stopmetadataHttpClienthas always set (retry_after_ms,reason,max_body_bytes).Types.*new/1refuses 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 andDecimalare unchanged, and free-form maps (details,line_items,metrics) may still hold one. BREAKING ONLY FOR A VENUE THAT PASSED A FLOAT TOnew/1; struct literals are not checked, as before.OrderBook.new/1refuses levels that are not{price, quantity}pairs ofDecimals (or integers), and a book not sorted best price first (bidsmay not rise,asksmay not fall), which its moduledoc has always made part of the contract.OrderBookDelta.new/1refuses a level whose side is not:bidor:ask, or that is not a{side, price, quantity}triple.AuctionImbalance.new/1refuses an:auctionoutside:opening | :closing. Both were stated in the typespecs and checked nowhere.HttpClient.provideris typedatom() | String.t(). It saidString.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_typegains:trailing_stop,:trailing_stop_limit,:market_on_closeand:limit_on_close, the four typesCapabilitiesalready let a venue declare. A venue could say it accepts a trailing stop whileOrder.new/1refused 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/1refuses an:instrumentor:statusoutside the closed vocabularies in its typespecs, raisingArgumentError(Validate.one_of!/3, asFill,TradeandPositiondo). 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 frominstrument_from/1/status_from/1or a local mapping onto:tradable | :delisted | :unknown, so none is affected.Instrument.status_from/1's doc namedtrading_disabledas 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/1requiresmeasured_atandmeasured_againsttogether (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-listhistorical_timeframesnow raises a message naming the field instead of a bareArgumentErrorfrom list subtraction.Notice.new/3refuses camelCase and run-together spellings of a credential name indetails(apiKey,accessToken,clientSecret,XApiKey): names are compared with-and_removed. It also refuses the nine namesCredentialRedactionCheckalready 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 adetailskey.PollingFeed.update_symbols/2raisesFunctionClauseErrorin the caller for anything but a list or aMapSet. It is a cast: anilor a bare map used to raise inside the feed, which restarted and lost every runtime scope. Both accepted shapes behave as before.HttpClientreads aRetry-Afterof" 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_statusdoc now says 429 is not handed back (it never was).
Added
HttpClientoption:max_body_bytes(default 16 MiB,16_777_216) bounds a response body as it is received. The request is streamed with Req'sinto: 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]withresult: :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 stepinto:skips and this pipeline never enables it (noaccept-encodingis sent), so decoding,decode_body: falseand 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 raisesArgumentError.
Fixed
CredentialRedactionCheckfails a struct whoseInspectshows 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.LinkSafetyCheckseesProcess.spawn(fun, [:link])and:erlang.spawn_optwith a literal:linkoption as link-creating calls.[:monitor]alone is not flagged.
[0.3.72] - 2026-10-11
Changed
OptionContract.new/1refuses a:rightthat is not:callor:put. BREAKING ONLY FOR A VENUE THAT BUILT A CONTRACT WITH ANOTHER VALUE ("call",:CALL,:long). Such a value built and then raisedFunctionClauseErrorfromin_the_money?/2in the consumer's process. It now raisesArgumentErrorat construction, naming the field, likeFill,TradeandPositiondo for their closed vocabularies. A struct literal is unaffected.Withdrawal.new/1,Conversion.new/1,StakingTransaction.new/1,FinancialStatement.new/1andCorporateEvent.new/1refuse a value outside their closed vocabulary (:status,:status,:type,:kind,:kind: exactly the values in each@type), raisingArgumentError. A grep of the five venue repos found no caller ofnew/1on any of them (they build struct literals), so no venue is affected.Telemetry.endpoint/1strips 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.ValidateraisesArgumentErrorfor an improper list in a field, naming the field, instead ofFunctionClauseErrorfromEnum.each/2.- Conformance assertion 2's
capabilities/0task 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/0andnameable/0return a list built at compile time. Both rebuilt and sorted it on every call, andnameable?/1runs per candle on a consumer's write path. Same lists, same order.VolumeProfile.point_of_control/1takes 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_linkof any arity.UnwiredCheckexcusedstart_link/1only, sostart_link/0,start_link/2and the/2a default argument exports beside/1were reported as unwired.LinkSafetyCheckexcusedstart_link/1,2andchild_spec/1,2, so aGenServerwithdef start_link, do: GenServer.start_link(...)was flagged as linking. Both now excusestart_linkandchild_specby name. These loosen; no venue newly fails. LinkSafetyCheckalso scans@behaviour :gen_servermodules. OnlyGenServerwas named, so the Erlang spelling of the same behaviour was never examined. A venue with an Erlang-style:gen_servermodule that links a child with notrap_exitnow fails assertion 18. None of the five venues has one.CredentialRedactionCheck(assertion 19) renders withlimit: :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
PollingFeedapplied a refusal or an error for a symbol removed in flight. Only the delivery arms checked scope, so{:refused, _}calledon_refusalfor a symbol the consumer had unsubscribed and latched it inrefuseduntil the nextupdate_symbols/2, and{:error, _}counted towardfailures_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/3withraw_status: truequoted the whole body in its"HTTP 4xx"message. It is capped at 2 KB like every other body-bearing message.HttpClientwithrate_limit_per_endpoint: trueand a URL with no path metered a bucket named"<provider> "; it is now"<provider> /".CanonicalPair.to_canonical/2with""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 barefutureis dated on some venues and perpetual on others (Coinbase'sproduct_type: "FUTURE"covers both, told apart byfuture_product_details. contract_expiry_type,PERPETUALorEXPIRING). Calling a dated future a perpetual was a nearby substitute."swap","perp"and"perpetual"stay:perp.instrument_typegains:future, and the newInstrument.instrument_from/2takes the type plus the venue's expiry kind:"perpetual"gives:perp,"expiring"gives:future, anything else falls back toinstrument_from/1. A venue that fed Coinbase'sFUTUREtoinstrument_from/1must move to/2or its derivatives become:unknown. Notice.new/3refuses a credential-shaped key at any depth indetails, 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 underdetailsand 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_receiveinpolling_feed_test.exs,contract_teeth_test.exsandfanout_test.exsthat 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/1raised on a price key spelled"NaN"or"Infinity".Decimal.parse/1reads both in full, so the tie-break handed a non-finiteDecimaltoDecimal.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:onuse DpExchange.Core.AdapterContract. A venue can now declare its own:order_request,:order_requestsand:order_changes, each replacing the suite's generic default. Assertion 31's extension in 0.3.68 drovereplace_order/4andplace_orders/3with the generic%{price: 1}edit and a:gtcbatch 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/3also callsplace_orders/3,replace_order/4andclose_position/3when each is declared active, and offers every id they mint to the fake'sget_order/3andcancel_order/3(reads before cancels, each distinct id once). A refusedplace_orders/3entry and a refusedclose_position/3are allowed answers and not failures; a wrong-length batch, an unreadable batch entry, areplace_order/4refusal and an un-allowedclose_position/3shape fail closed. A venue whose fake breaks on these calls will newly fail 31. Violations gain a:minted_bykey. Still 31 assertions. Order.new/1refuses aside,order_typeorstatusoutside the atoms its types state (nilstill allowed: "the venue's word, or nothing").:canceledor"OPEN"built and matched no branch of a consumer'scase. No venue callsOrder.new/1; struct literals are unchecked as before.DefaultRateLimiter.acquire/3refuses a:timeoutthat is not a non-negative integer or:infinitywith{:error, {:invalid_timeout, value}}. The server comparedwait_ms > timeoutin Erlang term order, so"30"never timed out (fail open) and:infinityalways did.
Fixed
PollingFeed.update_symbols/2crashed the feed on an enumerable that is not a list (Map.take/2was handed it as given), losing every runtime scope on the restart.FakeInjectionqueued outcomes popped in aTaskwere 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 anAgentthe configuring process starts and links; the process dictionary holds only its pid, found through$callers, and each pop is oneAgent.get_and_update/2. When the queue is exhausted behaviour is as before. A link ignores a:normalexit, so a monitor also ends the agent when the configuring process exits for any reason.PollingFeedno longer dies with a raising:sink,:on_refusalor:on_notice. The callback runs inside the feed, so its raise restarted the feed and lost everyupdate_symbols/2scope. 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/3raisesArgumentErrornaming a badmswhere it raisedFunctionClauseError, and its doc no longer says it "cannot fail".HttpClient.request/5raisesArgumentErrorfor a:retry_attemptsor:retry_delaythat 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/1skips a$callerspid on another node whereProcess.info/2raisedArgumentError.HttpClient.parse_rate_limit_headers/1and the 429Retry-Afterread accept atom header names whereString.downcase/1raised 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/3is active, the suite places an order on the fake and hands the returned id to the same fake'sget_order/3(must answer{:ok, %Order{}}) andcancel_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 whatplace_order/3returned. The decision is the new publicDpExchange.Core.OrderRoundTripCheck.run/3, tested against fakes that fail it. An activeplace_order/3that answers anything but an order fails the group rather than skipping it.get_order/3andcancel_order/3take theirendpoint_opts:entry where the venue declared one and the options the order was placed with where it did not.
Changed
LinkSafetyChecksees a link through a module held in a variable, and countsTask.asyncandTask.async_stream(which link the caller to the task) as links. It matched only a literal-atom module, sostate.socket_module.start_link(opts)— how a package lets its tests inject its socket — linked a child with notrap_exitand passed. A process-behaviour module with no abstract code is now{:error, {:no_abstract_code, module}}instead of aMatchError, and the abstract code is read for process-behaviour modules only, where it was loaded for every module underlib_rootand discarded.CredentialRedactionCheckrefuses a scanned module it cannot load with{:error, {:not_loadable, module}}. It answered "not a struct" and dropped it, so aCredentialsthat leaks and one never examined looked the same.UnwiredCheckexcuses macros (MACRO-<name>exports). An expanded call leaves no edge in the call graph, so a macro used insidelib/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.mdhad 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;
CredentialRedactionCheckchecks twenty-seven.adapter.mdand the check's moduledoc now list them all. - Prose said
dp_exchange_schwab'sFeeddials its Streamer unconditionally frominit/1. That was fixed in9973c0don the day it was written;adapter.mdand a comment inAdapterContractstill described it as current, againstLinkSafetyCheck'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/1checks the scalar fields' kind.supported_quotesmust be a list of non-empty strings ([:USD]built the right pair strings and failed everyin ["USD"]),max_candles_per_requesta positive integer ornil(0,"300"and300.0were accepted),measured_ataDateornil, andmeasured_againsta non-empty string ornil. A string date or an emptymeasured_againstread as provenance and carried none. All five venues declare these correctly.Fill.new/1,Trade.new/1,Position.new/1,OrderLeg.new/1refuse a value outside the vocabulary their typespec states.sidemust be:buy | :sell(Tradealsonil) or:long | :shortfor aPosition;Fill.liquidity:maker | :taker | nil;OrderLeg.ratioa positive integer andposition_effect:open | :close | nil;Trade.brokena boolean.Positionbuilt:buywas read by a consumer matching:long | :shortas no side or the wrong one. Venues build these as struct literals, so none is affected.Validate.one_of!/3andValidate.positive_integer!/2are new and public.ApprovedAddress.new/1refuses astatusother than:pending | :active | :rejected, andApprovedAddress.usable?/2answersnil("unknown, not usable") for a struct literal carrying any other, where it raisedFunctionClauseErrorin the caller's process.PollingFeedrefuses an unusable duration at start.interval_msandfetch_timeout_msmust be positive integers andstart_delay_msa non-negative one; anything else stops the feed with{:invalid_option, key, value}.interval_ms: 0re-polled the venue as fast as the fetch returned, and a negative or non-integer value raised insideinit/1namingsend_after/3rather than the option.Fanout.deliver/4raises on a:max_queue_lenthat is present but not a positive integer.queue_len >= "10000"is false for every queue, so a string or:infinityswitched back-pressure off exactly asnilonce did (nilstill means the default).HttpClient.build_auth_headers/5raisesArgumentErrorfor a missing,nilor empty credential under:bearer,:basicand:hmac_sha256."#{nil}"is"", so an unset config value builtAuthorization: Bearerand the request went out looking signed.DpExchange.venue/1refuses a name containing.or/.Macro.camelize/1turns/into., so"schwab/capabilities"resolved to any module nested under a venue that exportscapabilities/0.CanonicalPair.to_exchange/2returns a canonical symbol with no base ("-USD") uppercased and untouched, where it returned the bare quote"USD"on a concat venue.Core.AdapterContractgroup 1 fails whenfake:orsample_pairs:is missing. Groups 14, 20, 23 and 25 skip on an emptysample_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.CredentialRedactionCheckrecognises 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 withdefstruct [:secret_key]printed in cleartext and passed.credentialsis left out on purpose: it normally holds a struct that is itself redacted.
Fixed
HttpClientwrote the full request URL to the log.request/5's debug line printed the URL whole and the retry warning keptuser: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 useTelemetry.endpoint/1, as the telemetry metadata already did.HttpClient.get/3undiddecode_body: false. The option (0.3.65) hands back the bytes so a JSON number keeps its digits, andget/3then decoded them with native floats, losing the digits one function later for the only caller that had asked otherwise. Withdecode_body: falseit now decodes withfloats: :decimals, so a number is aDecimal.PollingFeedcrashed on a malformed bulk refusal list. A{:refused, refusals}with an element that is not{symbol, reason}raisedFunctionClauseErrorin the feed, and the restart lost every runtimeupdate_symbols/2scope. It is now a failed cycle,{:bad_fetch_result, {:refused, refusals}}.Notice.new/3raisedProtocol.UndefinedErrorfordetailskeyed 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 nofake:, 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, theDpExchangemoduledoc and the status section above now say~> 0.3.0.
[0.3.65] - 2026-10-11
Added
HttpClient.request/5passes:decode_bodythrough toReq. 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.567890123456789arrives as1234.567890123457).decode_body: falsereturns the bytes for the venue to decode withJason.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/1without:statusis now:unknown, not:tradable. An instrument built without a status was reported tradable on no evidence — the substitutionstatus_from/1exists to refuse. Every venue package already passes a status on its real path; a caller that relied on the default must now passstatus: :tradableitself.HttpClient.build_auth_headers/5raisesArgumentErroron an unknownauth_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 manyCore.Venuecallback docs ("Optional, and ...") said a package implements what its venue serves and may omit the rest. Onlylist_instruments/1,quantization/1andcoverage_by_kind/1are in@optional_callbacks; every other callback must be defined, and a venue that does not serve it declares it:unsupportedand answers{:error, :not_supported}.Core.Venue's moduledoc now says what "Optional" means there. usage-rules/feeds.mdsaid back-pressure "drops oldest". It drops the newest: a sender cannot remove what is already in a subscriber's mailbox, andCore.Fanoutstops sending once the mailbox passes the bound (10,000 by default).Core.Venue.subscribe/2already said so; the guide now agrees.AdapterContractmoduledoc said "Twenty-one groups". There are thirty.HttpClient:request/5's options now list:rate_limit_blocking,:weightand:max_retry_after_s, which were read but undocumented.:account_id,:user_idand:operationare documented as forwarded to the limiter (the default limiter ignores them), where the docs claimed account-aware limiting and an:operationdefault of"default"that nothing set.build_auth_headers/5documents the function form ofauth_typeand that an unrecognised scheme yields[].request_errorsaid "Three shapes" and listed two.DefaultRateLimitermoduledoc said every callback returns{:error, :not_started}.record/3andpenalize/3cannot fail and answer:ok.inspect_provider/2is documented as returning{:ok, map}, as its spec says.PollingFeed.start_link/1now documents its options and their defaults.Types.Validateandusage-rules/adapter.mdnow saynew/1also refuses aNaNor infiniteDecimaland aNaiveDateTimein any field.usage-rules/symbols.mdsaid quotes must be listed longest-first.CanonicalPairhas sorted them itself for some time; the order no longer matters.SymbolNormalizersaid malformed input degrades to"UNKNOWN"; it comes back uppercased.CanonicalPair.to_exchange/2said a bare symbol comes back unchanged; it is uppercased and alias-reversed.Instrument.new/1said it normalises and defaults conservatively. It does neither: it validates, and an omittedstatusis:tradable. Passstatus: :unknownwhen there is no status to report.RateLimitBehaviourno longer calls itself "future" or says "the host" records onHttpClient's behalf;usage-rules/environments.mdsays environment resolution is each venue package's, built onCore.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.
AdapterContractassertion 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 readmix.exsdeps withfor {dep, _rest} <- deps, which silently drops any dependency declared with both a requirement and options. Now decided byAdapterContract.permitted_apps/2andforeign_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.AdapterContractassertion 17 (credential gate) never exercisedplace_order/3orreplace_order/4. Its credential-stripped call rebuilt the argument list without the per-callback overrides the other assertions use, so it sentplace_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 acredential_benefit: :requiredvenue, 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/3andget_trade_volume/2were 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 fromCore.Venue's@callbackspecs (AdapterContract.credentialed_callbacks/0), andplace_orders/3,preview_order/3andpreview_replace/4are shaped as the orders they take. - Every other non-symbol callback was called with arguments of the wrong type. By arity
alone
stake/3got"1h"where itsDecimalamount belongs,withdraw/5five keyword lists,get_fx_rate/3"1h"for itsDateTime,quantization/1a keyword list for its symbol; each fake raised and the raise was accepted. Sixteen callbacks now carry theirCore.Venuespec'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, soVolumeProfile'sbuy_at_price/sell_at_price,OptionChain.expiries(Decimal strikes as keys) andStakingBalance.by_providercarried a NaN straight pastnew/1, andVolumeProfile.point_of_control/1then raisedDecimal.Errorin the consumer's process. Map keys and values are now scanned; structs other thanDecimal/NaiveDateTimeare still not descended into.HttpClient: the[:dp_exchange, :request, :exception]event lacked:stacktraceand:status, both documented inCore.Telemetry. A handler matching on either never saw them.:stacktraceis the rescued stacktrace and:statusisnil.get/3's@specalso named{:error, String.t()}though it returns{:exchange_error, provider, reason}whenever:provideris given; it now usesrequest_error().Telemetry.endpoint/1: a raw@in a URL password left the tail of the password in the label.https://user:p@ss@host/xwas stripped tohttps://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/1turned[symbols: ["BTC", "ETH"]]intosymbols=BTCETH, a well-formed request for a product that does not exist. A list, tuple or plain-map value now raisesArgumentError; 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 whilecoverage/1reported 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:symbolraised in the feed process, and the restart lost runtimeupdate_symbols/2scope. They are recorded as{:bad_fetch_result, _}.PollingFeedtelemetry names:provider, not:label, the same as its notices.Fanoutno longer raises on a subscriber pid on another node.Process.info/2andProcess.alive?/1raise 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/1refuses NaN, infinity andNaiveDateTimeinside lists and tuples too. The check was top-level only, so anOrderBooklevel{NaN, qty}built.CanonicalPair.to_exchange/2raises 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:orsupported_quotes, instead of looping zero times and passing.
[0.3.60] - 2026-10-10
Added
Types.Conversionstatus:cancelled. It means withdrawn before it settled. Coinbase reportsTRADE_STATUS_CANCELEDseparately fromTRADE_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/2says 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/3is typedresult(Order.t() | :cancelled). It saidOrder.t(), which three of five venues never returned: Coinbase, Webull and Schwab cancel endpoints answer success with no order data.:cancelledis now the declared answer for that case. A caller wanting the order asksget_order/3.
[0.3.58] - 2026-10-10
Fixed
- Assertion 5 called
replace_order/4with 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 wayplace_order/3now 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
:unsupportedendpoints, 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/0check acceptedniland 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
MatchErroron fewer than two pairs. - Group 5 counted a fake that raised as conforming.
Timeframe.aligned?/2called a millisecond-precision boundary unaligned. It compared the microsecond tuple{0, 3}with{0, 0}, andfrom_unix!(ms, :millisecond)and.000Zstrings both produce{0, 3}.boundary/2rounded a pre-1970 time into the next bucket. It now usesInteger.floor_div/2.Types.*.new/1refuses NaN, Infinity andNaiveDateTimein any field. A nil check letDecimal.new("NaN")build, and that later raises insideDecimal.compare/2in the consumer.Notice.new/3refuses anilprovider and a non-DateTime:at, and reads-as_in credential keys (api-key,X-Api-Key). It also refusesclient_secret,x_api_key,app_secret,signing_keyandcookie.Telemetry.link_down/2raised on a non-string reason inside the venue's feed. A raw reason is now inspected.endpoint/1strips userinfo and the fragment as well as the query.Capabilitiesaccepted anyDecimalasmax_leverage, including0, a negative and NaN. It must now be positive and finite.
Documentation
usage-rules/testing.mdsaid 23 assertion groups; there are 30. The Telemetry moduledoc now lists thecountand conditionalbytesmeasurements 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
HttpClientsigned 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. Withrate_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-Afterin 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: 86400held 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: athrowin a fetch killed the feed.safely/1caught exits and rescued exceptions only. A throw is now a failed fetch,{:throw, value}.PollingFeed.status/1reporteddelivering: truefor a feed dark for hours. It readlast_okas ever-delivered. It now uses the same window ascoverage/1.PollingFeed: a refusal was reported every cycle. The typedoc says once.on_refusalnow 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. Atatat 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_optionsis a keyword list only. A map was in the type, and every reader of it raises on one.
Documentation
:retry_attemptsapplies 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'sburstis the count of back-to-back requests from an idle bucket.burst: 0makes even the first request wait. Aweightaboveburstnever 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/:resumedon every message, and each flip was a:degradedor:infonotice 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:droppingand one:resumed. Every venue's feed delivers throughFanout.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 calledrecord/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 usesacquire/3with a zero timeout, which reserves only if there is room now and commits nothing otherwise;check/3is 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-Afternow holds the whole bucket, not only the request that drew it. After one Webull 429 withretry_after=5s, requests paced at the bucket's own 1/s drew 26 more in 31 s.HttpClientnow holds the most specific bucket (the endpoint's, underrate_limit_per_endpoint) for the stated time through the new optionalRateLimitBehaviour.penalize/3, whichDefaultRateLimiterimplements. 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,HttpClientacquired 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, becausecheck/3reserves nothing.
Added
rate_limit_per_endpoint: truealso 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 45429s withretry_after=5son Webull's crypto bars.
[0.3.50] - 2026-10-02
Added
Quote.volume_windowsays what quantityQuote.volumeis::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 isnilwhenvolumeisnil.volumeused 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/1refuses an unknown window, and refuses a window on a quote with no volume.Quote.volume_windows/0lists the values.- Conformance assertion 30: a
Quotefrom a venue'sget_price/2that carries a volume must name its window. Measured ondp_exchange_schwabbefore its fix: 1 failure.
[0.3.49] - 2026-10-01
Fixed
- A
Noticebuilt without amessagenow carries one, made from its provider, its kind anddetails.reasonwhen present, e.g."coinbase link down: {:remote, :closed}". Several venues builtlink_down,link_upand other notices with no message, so a host loggedlink_down: (no message)with the reason buried indetails. dp-exchange-core issues #33 and #41 were the same gap found one venue at a time. An explicitmessage: nilis kept, since a venue passes it on purpose to say "the venue sent no text".
[0.3.48] - 2026-09-29
Fixed
- A bulk
PollingFeedwith 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 afterupdate_symbols/2fetches.
[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: truestill hands a 4xx back whole. - A negative
Retry-Afteris no longer passed on as a wait. The caller was told to "retry after -30s" and the rate-limit telemetry carried a negativeretry_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/5acceptsheadersas 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 thesigned_headerstype.
[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
Feeddrops a payload for a symbol it does not want. Every fake took them as given, sobtc-usddelivered nothing against the fake while the real package deliveredBTC-USD. The assertion subscribes each fake to its first sample pair in lower case and requires delivery under the canonical symbol. Checked againstdp_exchange_coinbase: it fails on that fake before its fix and passes after.ReferenceVenueupper-cases too.
[0.3.44] - 2026-09-28
Fixed
PollingFeedno longer delivers or counts a symbol that is no longer wanted. A fetch runs in a task, andupdate_symbols/2can 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 intolast_ok. Socoverage/1answered:internal_pollfor 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_robinhoodanddp_exchange_schwab, the two packages that run aPollingFeed, 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
reqis now required at~> 0.7, because every request failed on Req 0.5 and 0.6 from 0.3.38 on. 0.3.38 madeHttpClientpass:request_timeoutso that:timeoutbounds 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, gotunknown option :request_timeoutfrom every call. This package's own lock was on 0.7.4, so nothing here saw it.script/check_dependency_floor.shfound 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
orrequirement 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
decimalmay 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 as1e1000000000is rejected rather than materialised.~> 2.0stopped a consumer from taking that version. The full suite passes on 3.1.1. Under 3.x,Decimal.parse/1refuses more than 34 digits andDecimal.to_string/2raises 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/1andcoverage/1must return a value, never exit the caller. On 2026-09-27 three of five venues exited:noprocthere 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:feedpasses its ownabsent_feed_opts:. Checked againstdp_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
:timeoutnow bounds the whole request, as its doc always said. It was passed only as Finch'sreceive_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.:timeoutis now also passed as Finch'srequest_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-resetheader no longer stalls the calling process.parse_rate_limit_headers/1runs on every response. It treated any value at or below one year as a delta in seconds, negatives included, and passed it toDateTime.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 answersreset_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_tradesandget_trade_historyare documented as oldest first, by:timestamp, asget_historical_pricesis. No venue ordered them before.dp_exchange_coinbase0.3.50,dp_exchange_gemini0.2.55 anddp_exchange_webull0.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_pricesis documented as oldest first, byopened_at. Every venue that serves candles already sorted this way except Schwab, which does fromdp_exchange_schwab0.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.Typesstruct itsCore.Venuecallback promises?Found in
dp_exchange_schwab, whoseget_order/3andget_orders/2— typedresult(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@callbackspecs by the new publicpromised_types/0, usingCode.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 aCore.Typesstruct. 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/4returned aQuote, not aCandle— 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.:sinkis read withKeyword.fetch!/2, which proves only that the key is present —sink: nilpasses 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 namingapply_result/3rather than the option that is wrong. It is the shape this module'sinit/1already records for:on_refusal, arriving through the one required function theDpExchange.Core.Config.opt/3fix could not reach — a required option has no default to fall back to.init/1now refuses a:sinkthat is not a one-arity function, and a:fetchor:fetch_allthat is present but not one, with{:invalid_option, key, value}— at start, beside the existing:no_fetcherrefusal. 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/2withto: nilmust deliver to the caller: neither to nobody nor by raising. This family forwardsoptsunchanged, so an option a caller's own caller never set arrives askey: nil, andKeyword.get/3substitutes its default only for an absent key — the trapDpExchange.Core.Config.opt/3's moduledoc says was closed "as a class". It had not closed everywhere.Found on
:toin all five venue packages at once, failing in OPPOSITE directions: the realFeedputnilin its subscriber set andFanout.deliver/4resolved it to nobody, sosubscribe/2answered:okand data never arrived; the fakes raised onsend(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:tofails it. Deliberately not asserted for:name, wherenilis OTP's own "do not register" rather than "unset".
Fixed
Fanout.deliver/4with a forwardedmax_queue_len: nilswitched back-pressure off.Keyword.get/3handednilon as the bound, andqueue_len >= nilis 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!/2deliberately still REFUSESnilatinit/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/2raised onto: 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, _})scheduledwanted - 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 ofstate.symbolswhile its pending timer went on existing, so the re-add counted it as new and armed a second.reschedule/2then re-arms both, forever.Measured, per-symbol mode at a 200ms interval, one symbol churned and one left alone as a control:
before after 3 remove/re-add cycles churned symbol 5 fetches/s 20 fetches/s (x4) control symbol 5 fetches/s 5 fetches/s Every cycle stacks another timer and none of them decay. This is exactly the multiplier the
update_symbolscomment 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 — andupdate_symbols/2subtracts that rather thanstate.symbols.reschedule/2re-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_robinhooduses:fetch_alland is unaffected.PollingFeedis 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/2adds to the live set,update_symbols/2replaces it,unsubscribe/2removes 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/2REPLACED the first — in each of themsubscribe/2andupdate_symbols/2were byte-for-byte the same line — while every realFeedin the family unions (wanted: MapSet.union(state.wanted, MapSet.new(symbols));dp_exchange_robinhoodspells itcurrent ++ 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/2exists to "change a live subscription's symbol set" andDpExchange.Core.Venue.unsubscribe/2to stop delivery for named symbols, and neither has any purpose ifsubscribe/2already 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/2to replace fails it, and makingupdate_symbols/2additive fails it too. The assertion also refuses asample_pairstoo short to tell "added" from "replaced", for the reasondocs/reference/core/assertion-coverage.mdrecords 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/2was:okand nothing else, leaving the reference implementing two of the contract's three subscription operations.update_symbols/2REPLACES the live set — that is the whole difference between it andsubscribe/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/1reporting 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_rootthat 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 stalepackage_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.27 this change dp_exchange_coinbase48 tests, 0 failures 48 tests, 3 failures all five venues — 3 failures each UnwiredCheck.run/3,LinkSafetyCheck.run/2andCredentialRedactionCheck.run/2now answer{:error, {:no_modules_scanned, lib_root}}, and the three assertions carry a message namingpackage_root:as the thing to fix — that error reaching a bareassert {: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 itsrefuteheld 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
Credentialsstruct's redactingInspectis removed; every capability-derived collection the suite iterates is non-empty on every venue; and assertion 13 exercisesCore.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/1reports what is actually arriving, so with nothing subscribed it answers%{}; measured on all five packages,%{}every time. Assertion 14'sfor {_symbol, route} <- ...ran zero times and assertion 15 comparedMapSet.new([])toMapSet.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:break published 0.3.26 this change coverage/1reports a route that is a claim (:will_stream)48 tests, 0 failures 48 tests, 1 failure coverage_by_kind/1names a symbolcoverage/1does not48 tests, 0 failures 48 tests, 1 failure Both now drive the fake and call
subscribe/2with the venue's ownsample_pairsfirst, 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 thesubscribe/2call, 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/1must answer%{}andcoverage_by_kind/1must 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_robinhoodwas the one partial exception, and it is worth naming because it shows how thin the accidental coverage was: itscoverage_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/1reported a claim, not an observation — and the new assertion caught it on its first run. It returnedMap.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/1exists to forbid, in the module every venue author reads as the worked example and the fixture Core runs its own conformance suite against.subscribe/2now 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/2removes them, andcoverage/1reports that. Process-scoped, because the suite requiresasync: truesafety 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/4compares 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 fordp_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_geminifakecontract suite one level a side, as shipped 48 tests, 0 failures — and nothing can change that a second, deliberately misordered level 48 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 ondp_exchange_webull, and an honest:unsupportedskip 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.mdalready 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?DefaultRateLimiteraccepted alimit: 0and then died on the first request.:limitis the divisor in the GCRA arithmetic, so a zero reacheddiv(_, 0)and raised:badarithinside the limiter'sGenServer, on the first call — the exact crash loop thatvalidate_limits!/1exists to prevent, which it had been letting through because its guard readl >= 0. Under a supervisor it restarts and crashes again on the next request; the message namesreserve/3, not the configuration that is wrong. Andrecord/3cannot fail by design, so it kept answering:okto 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.ceilingdeclares:limitasnon_neg_integerand means it —limit: 0is 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 acapabilities/0ceiling 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/1now refuses a zero rate with a message that separates the two meanings, so a reader does not "fix" it by deleting the zero fromcapabilities/0, where it is correct. This is the second wayCore.Capabilities.ceiling/0andDefaultRateLimiter'st:limit/0fail to compose —:burstoptional-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: 0starts, on the stated reasoning that a venue package "relies on it being expressible". It relies on it being expressible incapabilities/0; it deliberately does not hand it to the limiter. The assertion stopped one line short of the question that mattered —start_linkreturning{: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/1crashed on a rate the venue did not state, and accepted one that is not a number. It handed a binary straight toDecimal.new/1, which is wrong in both directions:- It raised.
Decimal.new("")andDecimal.new("n/a")raiseDecimal.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 aDecimaland 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
nilfor anything it cannot read — the convention every venue's owndecimal/1already 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, andnilin place of a NaN rate is both detectable and correct.- It raised.
CanonicalPair.to_exchange/2appended 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 aBASE-QUOTEpair 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/1reports{: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_coinbaseanddp_exchange_robinhoodboth map withsep: "-"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/2already 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/4calledresolve/1(Process.alive?/1) and thendeliver_one/5calledProcess.info(pid, :message_queue_len)— and that second call already answersnilfor a process that is gone, whichdeliver_one/5already 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/4itself, 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/2does more work thanProcess.alive?/1before 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 bugwatch/2andforget/2exist 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/1is 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 usedProcess.alive?/1, so it has been re-measured rather than left to drift, and the derived figure beside it (the cost of 4258level2frames 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/1usedDateTime.from_unix!/1, which RAISES rather than returning an error, andparse_rate_limit_headers/1runs on every response. A venue moving that header from seconds to milliseconds — ordinary drift — sends a value that isinvalid 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.nilnow, 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/2took{integer, _rest}fromInteger.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 intoparse_reset/1, whose own comment says it answersnil"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", whichInteger.parse/1refuses outright, so it never reached the partial case.VolumeProfile.point_of_control/1returned the HIGHER price on a tie, where its own doc promises the lower. The tie-break wasprice_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'sget_volume_profile/4passes 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
PollingFeedtakes a:provider, soNotice.providercan be trusted for routing. Its notices namedstate.label, a string. A venue's ownFeedbuilds notices with the venue ATOM, so the same package emitted both —dp_exchange_robinhoodsent:robinhoodfrom its feed and"robinhood"from its poll, anddp_exchange_schwabsent:schwabbeside"schwab-fallback-poll". A consumer routing notices byprovidersilently 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.:providerdefaults to:label, so every existing caller answers exactly what it did before and no venue has to change to keep working. A venue that wantsproviderto mean the venue passes its own atom, andlabelgoes on doing what it is good at — naming WHICH feed insidemessageanddetails.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.providerhad no documentation at all.kindandseverityeach carry a@typedoc; the field a consumer actually routes on carried none, while its type quietly permittedatom() | String.t(). A consumer reading the struct would writenotice.provider == :robinhoodand never learn why half the notices did not match. It now has a@typedocsaying which values occur, why the string exists, and what a venue package should do about it.
[0.3.16] - 2026-09-14
Added
assertions/0is 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.OrderBookstates thatbidsdescending andasksascending 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 anOrderBookwere returning the venue's row order;dp_exchange_coinbasewas 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/0is{Decimal.t(), Decimal.t()}and one package was carrying{nil, _}levels, sohd(bids)could hand a caller anilbest 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 testdial the live API, and feeding it an unsorted fixture would mean handing the suite aplug:, 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/1demanded a non-nil:idand:sidethat two venues cannot supply, and its typespec said they were never nil while a shipped venue returns one.@required_non_nilis now narrower than@enforce_keys— the same splitTypes.Balancealready makes, for the reason it states: state what is true rather than a stricter rule the packages then quietly violate.dp_exchange_webull'sticktopic publishes no per-print identifier, on the socket or on its REST tape, and its usage rules say so. Its decoder records thatTrade.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_geminion:side: "Absent means the venue did not say which side lifted, and neither answer is honest."dp_exchange_webullmatches only the venue's documented"B"/"S"and answersnilotherwise.
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
nilprice, quantity, timestamp or symbol went unchecked in every venue that built the struct literally to avoid raising on the id.@enforce_keysstill lists both, so a decoder that forgets the key entirely is caught; what is allowed is stating the absence.t:t/0now readsid: String.t() | nilandside: :buy | :sell | nil, which is what the family already produced.Consumers must treat
:idand:sideas optional. This is not a behaviour change —%Trade{id: nil}has been arriving fromdp_exchange_webullall 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.mdstates theBalancerule the type now enforces.:balancemay honestly beniland:currencymay 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 fromnew/1raising.It records what each
nilmeans for a caller: an unstated total is a real answer and must be read as unknown rather than zero, withavailable_balancebeside 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 testruns of this package overlapped.UnwiredFixturenames each fixture directory fromSystem.unique_integer/1, which is unique within a VM and restarts near 1 in the next, so two runs generate the identicalbeam_2,beam_3, … names.test_helper.exsthen made that fatal rather than merely confusing: it wipedtmp/unwired_check_testwholesale beforeExUnit.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,LinkSafetyCheckTestorCredentialRedactionCheckTesthappened 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) andtest_helper.exswipes only its own, which keeps the stale-beam guarantee that wipe exists for and cannot reach another run's files.run_root/0is 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
PollingFeedtests waited exactly one poll interval for a synchronisation message. Both setinterval_ms: 500and thenassert_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_startedafter 500ms": the message arrived, just late.Raised to 5s. Waiting longer cannot weaken either claim — that
status/1andcoverage/1answer 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 carrycredentials:, 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'sendpoint_optsandendpoint_symbolsnow 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.mdrecords 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/0exactly — 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 isopts. Both passed a hand-built[symbol, []]. Every venue declaringcredential_benefit: :requiredtherefore answered{:error, {:missing_credentials, _}}, and each assertion's own_refused_or_unsupported -> :okclause took that for an answer. Onlydp_exchange_gemini, which needs no credential, ever executed them.Measured, not inferred. Setting a fake's
observed_attonil— the precise defect assertion 14 exists to catch — and runningdp_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 fordp_exchange_schwab(no order book), 1 fordp_exchange_robinhood(noget_price/2, no order book).Both now build their arguments through
endpoint_args/2, andarg_value(:opts, _)carriescredentials:viaKeyword.put_new/3— so a venue's ownendpoint_optsstill wins, and assertion 17's deliberately-emptied credential is not fought.The same
_refused_or_unsupported -> :okclause 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 endpointcapabilities/0declares active now fails in all three, naming both honest remedies.
Added
endpoint_symbols:onuse DpExchange.Core.AdapterContract, a%{{name, arity} => symbol}for the endpoint whose coverage is narrower thansample_pairs:.dp_exchange_webullserves 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'sget_order_book/2arm 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/0says 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 inendpoint_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.mdbrought 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 thedp_exchange_coinbasehas_stakingfinding 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
Balancenames the asset it is a balance of.Types.Balanceenforces:currencyand itsnew/1refuses anilthere, but no venue decoder in this family callsnew/1— every one builds the struct literally, whichTypes.Validate's own moduledoc explicitly permits — so that check had never run anywhere, and four of the five packages readcurrencystraight 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.":balanceis deliberately not checked —Types.Balancestates it may honestly benilwhile:currencymay not, and an assertion covering both would forcedp_exchange_coinbaseto discard a realavailable_balanceto 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_webullneeds an:account_id,dp_exchange_robinhoodan:account_number,dp_exchange_schwaban: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.AdapterContractnow takesendpoint_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_hashin 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.Balancerequired a non-nil:balancethat every package in the family knowingly produces. Itsnew/1checked the full@enforce_keys, and its typespec saidbalance: Decimal.t().dp_exchange_coinbasederives the total from the venue's available and hold figures and carriesnilwhen either is missing — deliberately, on the recorded ground that "available 1, total unknown" and "total equals available" are different claims — anddp_exchange_geminihas its own recorded decision to carrynilrather 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.:balanceis nowDecimal.t() | niland out of the non-nil check, with the moduledoc saying when anilis honest.@enforce_keysstill guards its presence, so a decoder that never sets the field is still caught — stating an absence and omitting one are different mistakes.:currencystays 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 anilthere is a decode reading the wrong key, exactly whatTypes.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_000withlimit: 3a token returns every ~333ms, so the closingcheck/3was only refused if the acquires ahead of it finished inside that window. They normally did. On a loadedasync: truerun 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 toper_ms: 100_000, which puts every refill interval here past nine seconds while keeping the non-terminating division (100000/3is 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/1comment 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/4walks the whole set and callsProcess.alive?/1per 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_webull0.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.tcassertions 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× indp_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
:limitscrashed the rate limiter on its first request instead of failing at start.reserve/3destructures%{limit: _, per_ms: _, burst: _}, so an entry missing any of the three raised aMatchErrorinside the GenServer. A supervisor then restarts it, the next request crashes it again, and what a consumer sees is a crash loop whose message namesreserve/3rather 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.ceilingdeclares:burstoptional; this module'st:limit/0declares it required. So every venue in the family has to write its own littleto_limit/1to bridge them — all five happen to do it correctly — and a consumer wiringDefaultRateLimiterup directly against acapabilities/0ceiling, which is the obvious thing to do and the shape that reads as correct, got the crash loop instead of an error.init/1now 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
bursttolimitis deliberate, even though that is what@default_limititself 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 objectionFanout.max_queue_len!/2already 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:of0stays legal and is tested: it is a real declaration — a registration that granted no throughput, whichdp_exchange_schwabrelies 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 shapecapabilities/0returns.
[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/1skips a dead subscriber at send time, so no events accumulated for it — which is whatCore.Venue'sunsubscribe/2doc 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/4walks the whole set and callsProcess.alive?/1on every entry, once per message. Measured:dead pids in set µs per fan-out 0 0.095 50 0.956 200 4.301 1000 22.842 Linear, and at a thousand accumulated pids each message costs roughly 240× what it should.
dp_exchange_coinbase'slevel2channel 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/2monitors a subscriber so a feed can drop it on:DOWN, andFanout.forget/2cleans up on both:DOWNand 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/2accepts 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 compileslib/only — test files are compiled bymix test, which had no such flag. So every warning from a test file was permanent and green.Found by running
script/check_dependency_floor.shby 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 cannotworkflow_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/0andcoverage_by_kind/1are optional callbacks, guarded byfunction_exported?/3and then called directly — so every venue package that does not implement one got a compile warning out of a macro it did not write. Thevenue_does_not_serve/0site 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/1had no mitigation at all. Both are nowapply/3with a scopedcredo: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, _}fromget_symbols/1and always setsvenue_time, so the{:error, {:query_required, _}}branch was reported as "this clause will never match" andis_nil(top.venue_time)as a "comparison between distinct types" — for branches that are live in the packages that need them (dp_exchange_schwabgenuinely answers{:query_required, _}, and a venue that publishes no BBO time is the caseTypes.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 existingmix 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.DataProvideris 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.DataProvidersaidThe real contract says Why the difference matters decimal_string :: String.t()Decimalin everyCore.Types.*string arithmetic, silently provider: String.t()provider: atom()every match on :coinbasefailsbalance_datawith no timestampTypes.Balance@enforce_keysit"no way to tell a current balance from a stale one" — its own moduledoc price_datawith onetimestampvenue_time+observed_atsince 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.VenueandCore.AdapterContractnever 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. Andtest/dp_exchange/core/behaviours_test.exsasserted it declared exactly 24 callbacks — a green test pinning a dead contract, which isCore.UnwiredCheck's own line ("a test is not a caller") applied to a behaviour instead of a function.DpExchange.Core.FeedBehaviouris deleted too — a fourth restatement of the contract, with signatures that matched nothing.start_feed/2existed in no venue. Itsupdate_symbols/2was wrong fordp_exchange_webull, which takes credentials per call like every other endpoint in this family and so needsupdate_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'sroute()typedoc where the realcoverage/1lives: 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:streamnames a push rather than a socket, and why:internal_pollis 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.exsnow enumerates every module in this package that declares a@callback— read from the compiled beams, because@callbackinside aquote(whichCore.AdapterContractuses 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
:noneis 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, not0.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.8will 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.PollingFeednow emits[:dp_exchange, :link, :event]per delivered payload, and:link, :up/:link, :downmapped onto thenotice_statelatch it already had — so the telemetry inherits that latch's once-per-crossing property and cannot storm on a long outage.:link, :downis 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 — anddp_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. SeeCore.Telemetry's "Why the category is:linkand 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 —PollingFeedreceives a decoded body and hands on aCore.Types.*struct — so there is no point at which a byte count means whatlink_event/3's does on a socket.:bytesis therefore absent, not zero. Absent and zero are different claims and only one of them is true here: a consumer summing:bytesacross 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/3call 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/4against 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.HttpClientnow emits[:dp_exchange, :request, :start | :stop | :exception]frommake_http_request/5— the one place a request actually leaves the process, so a new endpoint cannot forget it — andCore.DefaultRateLimiteremits[: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.Telemetrygained the emitters themselves, rather than leaving five packages to name events by hand. Five chances to write:link_upinstead of[:dp_exchange, :link, :up], and the drift would be invisible: the wrong name emits successfully and never reaches a handler. It also keeps:telemetrya dependency of Core alone — a venue calling:telemetry.execute/3directly would be using a transitive dependency it never declared.Telemetry.endpoint/1strips a URL's query string for:endpointmetadata. 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, butendpointis 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/5carries 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:stopwould have reportednilfor 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 reportsnil: 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 onlyacquire/3.Core.HttpClientusescheck/3unlessrate_limit_blocking: true, and that option defaults to false. Emitting only fromacquire/3would 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_msis 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'ssubscribe/2doc said "a venue pushing faster than its subscriber consumes drops oldest beyond a stated bound and emits a:degradednotice saying so". All five venues fanned out with a baresend/2, in five identical privatefan_out/2functions, 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'slevel2channel 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, andcoverage/1reporting perfect health throughout, because the feed was delivering.deliver/4checks each subscriber's:message_queue_lenand declines to add to a queue already at the bound (default 10_000, per venue via:max_queue_len). It reports only transitions —:droppingthe first time a subscriber is found over,:resumedthe 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/2for:message_queue_lencosts 0.029 µs againstsend/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/1is shared. All five venues had written the same pid-or-registered-name resolution privately. It moves here becausedeliver/4and a venue's notice path have to agree on what counts as a reachable subscriber.
[0.2.5] - 2026-09-10
Documentation
coverage/1now 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}fromhandle_disconnect/2, so the socket process survives a transport drop — noEXITfires, and every delivery-record reset path in every venue was keyed on a process death. Between a drop and a successful resubscribe,coverage/1answered:streamfor 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:streamindefinitely, on frames observed before the disconnect.The
@docnow states that observation is scoped to the current transport session: on:link_downa venue narrows coverage by the symbols that link carried, the same way it already narrows on the socket's process death and onunsubscribe/2. A consumer sees a brief, truthful dip bracketed by the:link_down/:link_uppair that exists for it.It also separates the two routes, which had silently diverged:
Core.PollingFeedapplies 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 ondp_exchange_schwabovernight 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 aCore.AdapterContractassertion — 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_streamableandhistorical_timeframeshad 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
1wbecause your equity bars serve it and then return a1dbar when a caller asks for1won crypto, and you have built the substitution this family exists to stop.dp_exchange_webullis cited as the worked example — it declares1w/1Mfor 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's1w/1M/1y, anddp_exchange_schwab's inability to declare:order_bookfor options alone, which is one of the two reasonsOPTIONS_BOOKstays 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.mdsat under## [Unreleased]— in the published tarball, sinceCHANGELOG.mdships 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.mdfor a### Changed — BREAKINGsection, then grep for every clause matching the old shape" — which needs version → change. Without it,### Changed — BREAKINGsays 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_gemini0.1.42's refusal-shape change was found after shipping, by reading a fix comment, whiledp_exchange_webull0.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-DDheading itself, in the publish job and beforemix 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 earlierheading. 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_schwabhas the same defect and is not one of their dependencies, so it could not appear in their table: six instances, all fixed here.- Going forward, the release pipeline cuts a
[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.
QuoteandOrderBookgained:venue_timeand:observed_atin 0.2.0, and nothing checked that:venue_timestays honest. Assertion 14 already made exactly this check forTopOfBook, which carried both fields from the start; 23 extends it to the two types that just gained them, forget_price/2andget_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.mdconsidered and declined: two named callbacks whose return type the contract already fixes, with no hand-maintained list to rot. Both gate onCapabilities.active?/2, so a venue declaring either:unsupportedis skipped rather than failed —dp_exchange_robinhooddeclares 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 — aDateTimefromDateTime.utc_now/0is 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
:timestampfield. Same reasoning asTopOfBook 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.QuoteandCore.Types.OrderBookno longer have:timestamp. It is replaced by:venue_time(the venue's own,nilwhere the venue publishes none) and:observed_at(when this package read it, always present) — the shapeCore.Types.TopOfBookhas had from the start.Why it had to break.
:timestampwas 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'sLEVELONE_*quotes, anddp_exchange_gemini's partial-depth books (the vendor's own AsyncAPI requires only[lastUpdateId, bids, asks]there, whereBookTickerrequiresE). 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
.timestampon these two types changes. In exchange they can express a policy they previously could not: store:venue_timeas the point time where the venue dated the frame, and where it did not, store:observed_atand 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.timestampis 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_atis mandatory on both types, at the consumer's request. That is what makes a strictly-honest nullable:venue_timeaffordable: everyone always has a usable time, sonilcan 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_timefrom a local clock. The rule that field carries is unchanged and absolute: whatever the venue gave us, ornil.dp_exchange_schwab's Streamer book is the model — it reads the venue'ssnapshot_timeand fails closed when absent.Trade,Fill,BalanceandOrderBookDeltaare unchanged and keep a single:timestamp. A sweep confirmed everyCandleandTradeconstruction in all five venues derives its time from a venue field, andOrderBookDeltafails closed without the venue'sE. 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.mdrecords thatQuoteandOrderBookcarry a single:timestampand 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 fromQuote.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.mdinstructs, receives it only when they choose it. Both type moduledocs and both offending call sites are already labelled in the meantime.
Documentation
Core.Types.QuoteandCore.Types.OrderBooknow record where their own rule is not kept.Quote's doc says:timestampis "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'sLEVELONE_*quotes carry the frame's arrival time, anddp_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]forOrderBookSnapshotwhereBookTickerrequires anE.The gap is in this contract, not only in those packages.
TopOfBookcan say "the venue did not stamp this" because it carries:venue_timeand:observed_atseparately;QuoteandOrderBookhave 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 readssnapshot_timeand 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 — 19lib/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/OrderBookwhatTopOfBookalready has (breaking), or add:venue_timealongside: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
CandleandTradeconstruction 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.mdgains "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 ondeveloper.webull.compublished for weeks behind a five-times-too-permissive ceiling; a WebSocket channel withdrawn fromdeveloper.gemini.comwith no changelog entry; and Coinbase's rate-limit pages, recorded as "could not be located", sitting in the vendor's ownsitemap.xmlthe 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 answers403to 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.mdgains "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, whereCore.PollingFeedran 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-onlycoverage/1timed out, the exit propagated out of the venueFeed'shandle_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 inhandle_info/2, and a socket connect inline inhandle_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_linklinks 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, becauseTask.async/1links 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_timeoutoncoverage/1/status/1, not just on writes, because a health check left onGenServer.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_downnotice, never a remembered one.
Documentation
usage-rules/auth.mdgains "Credentials are redacted inchild_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 (aCore.PollingFeed-shaped facade defaultssubscribertoself(), which resolves to the supervisor whenstart_link/1runs insideinit/1, so a different delivery target can only be set atstart_linktime). On that pathchild_spec/1never 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/1andwrap_opt/1were already public, which is all that path required. What was missing was anyone saying so. Assertion 22 asks whetherchild_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_secretinto a venue'sapp_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/1could kill a venue's feed — issue #28.Core.PollingFeedran its fetch inside a task and then blocked on that task insidehandle_info. The task bounded a hang; it did nothing at all for the mailbox. With@min_fetch_timeout_msat 30 seconds againstGenServer.call/2's 5-second default, acoverage/1orstatus/1arriving during an ordinary in-flight fetch was not unlucky — it was a guaranteed timeout. Indp_exchange_robinhoodthat exit propagated out of the venueFeed's ownhandle_call/3and killed it; the feed restarted from the static opts its supervisor holds, which never carry a consumer's latersubscribe/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_infostarts the task and returns immediately, socoverage/1andstatus/1answer 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.72by a consumer running the venue live, with theNeighboursblock of the crash report showing the called process insidebounded_fetch/2at 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, sostatus/1returned 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 thanGenServer.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 coverscoverage/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 throughinspect/1into theStart 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.CredentialRedactionCheckproves 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, andKernel.struct/2drops 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:testthis project's ownelixirc_paths/1also compilestest/supportinto that same directory — so a check named "nothing inlib/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 theInspectfallback implementation "in lib/", where no such reference exists. It now filters by each beam's own:compile_infosource path. Filtering is the repair rather than wideningallowed_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.mdis implemented and closed, asdocs/design/closed/2026-09-09_detecting-vendor-api-change.mdwith 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 isscript/check_doc_sources.shplus a committeddoc-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_coreitself 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
404on a pagedp_exchange_webullcited, which led to a rate ceiling five times too permissive against that venue's own per-endpoint table, on a venue whose documented penalty is429and 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 amanualclass for venues no machine can verify (Schwab answers403to 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:Capabilitieshas carriedmeasured_atall along and nothing ever read that age.docs/design/ideas/credential-gate-fixed-callback-list.mdmoved todocs/design/closed/2026-09-07_credential-gate-fixed-callback-list.md. It had been marked resolved on 2026-09-07 and left sitting inideas/— 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
AdapterContractno longer dials a live venue on an ordinarymix testrun. Assertion 14'sget_top_of_book/2check 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@venuedirectly instead of@fake, contradicting the@fakeattribute'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
@fakeis present (failing loudly, matching assertion 17's own pattern, rather than skipping silently — every one of the five venue packages already suppliesfake:) and call the fake instead of the real venue. The order-shape check was a second defect beyond the two originally reported: it calledpreview_order/3andreplace_order/4unconditionally, 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 soCore.HttpClient'sLogger.debug("HTTP Request: ...")line lines up with the test that triggered it), reverted after each: before this fix,dp_exchange_geminidialedapi.gemini.com/v1/symbolsand.../v1/pubticker/btcusd;dp_exchange_robinhooddialedtrading.robinhood.com/api/v2/crypto/trading/trading_pairs/;dp_exchange_webulldialedapi.webull.com/trading/instruments/crypto/profiles/list;dp_exchange_coinbasedialedapi.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_schwabmade no live request either before or after — it escapes only becausepreview_order/3andget_symbols/1both refuse locally (missingaccount_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.Venuecallback,Capabilitiesfield andNoticekind against the 19 existing assertion groups — closes three real gaps:Capabilities.new/1gains ahas_staking/staking-endpoint agreement check, andAdapterContractgains assertion 20 ("subscribed push shape") and assertion 21 ("historical timeframe discipline"). The full inventory — covered, partial, deliberately declined — lives indocs/reference/core/assertion-coverage.md.has_stakingmust 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" rulevalidate_orders!/1already applies tosupports_order_preview/supports_order_replace/supports_multi_leg_orders. Found live ondp_exchange_coinbaseduring this audit:has_stakingwas never declared in itscapabilities/0(defaulting tofalse) whilestake/3andunstake/3are real, active calls against Coinbase Prime — a caller branching onhas_stakingalone would conclude the venue does not stake at all. Confirmed by pointing that package'smix.exsat this Core with apath:dependency and running its own contract test: 23 of 42 tests fail, all from this one raise insidecapabilities/0. Reported for that package's own review; not fixed here, and nopath:dependency was committed —mix.exswas reverted immediately after. Keyed on an EXPLICIT:proven/:experimentalentry inendpoints, never onactive?/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 throughCapabilities.new/1.Assertion 20, "subscribed push shape":
subscribe/2's own doc makes an unconditional claim — the payload is aDpExchange.Core.Types.*struct, tagged withruntime_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 fromTimeframe.nameable/0the venue's ownhistorical_timeframesdoes not name and asks the fake for it; the answer must not be{:ok, _}(dp_exchange_robinhooddeclares the endpoint:unsupportedentirely 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_stakingfinding above, apath:dependency run and reverted per venue — withdp_exchange_coinbase's 23 failures being entirely thehas_stakingfinding, not these two. Seedocs/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 forget_top_of_book/2andget_symbols/1, so an ordinarymix teston Gemini, Robinhood and Webull today makes real calls to each venue's public API despitetest_helper.exsexcluding:tier2specifically to prevent that.usage-rules/testing.mdanddocs/guides/building-an-exchange-package.md's assertion counts move from 19 to 21;usage-rules/adapter.mdgains sections for both new assertions next to assertion 19's.usage-rules/adapter.mdgains a "Dependency floors are a claim exactly like a capability" section, and every venue package gainsscript/check_dependency_floor.shplus a weeklyfloor-check.ymlworkflow. Four real instances of the same defect shipped in one week: amix.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/3needs0.5.1, not0.5.0),dp_exchange_webull/dp_exchange_core(no_venue_contact, needs 0.1.68), anddp_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 withoutonly:down to the exact floor its own requirement names, then runsmix compile --warnings-as-errorsplus the package's ownAdapterContractconformance test against that pinned set — deliberately not the fullmix test(dp_exchange_coinbase'sfeed_test.exswas 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 (credoat 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 plusworkflow_dispatch, never onpush/pull_requestand never inci.yml'spublishjob'sneeds: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) indocs/design/closed/2026-09-08_dependency-floor-check.md.AdapterContractgains assertion 19, "credential redaction" — a struct defined in a package's ownlib/holding a secret-named field must redact it underinspect/1. Found live in four of five venue packages on 2026-09-07:Feed/Socketheld:credentialsas 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: aFunctionClauseError'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/:dbgcan see, not how a crash report or a stacktrace is formatted. Fixed identically in four repos, by wrapping the credential in a struct whoseInspectis derived withexcept:naming every secret field, at the point it enters a long-lived process:dp_exchange_coinbase4d00669(api_key,api_secret),dp_exchange_webull80eaf02(app_key,app_secret,access_token),dp_exchange_schwab336cbd8(access_token,refresh_token,client_id,client_secret),dp_exchange_robinhoodcfc4861(api_key,private_key).dp_exchange_geminineeded no fix — it signs and discards inside stateless pipelines and holds no credential in process state at all.Verified behaviourally, not by looking for
@derivein a venue's source.DpExchange.Core.CredentialRedactionCheckinstantiates a real copy of every struct a package'slib/defines (struct/2, neverstruct!/2, so@enforce_keyson an unrelated field never blocks construction) with a distinctive value in each secret-named field, and searches the actual renderedinspect/1output for it. A hand-writtendefimpl Inspect, for: YourStructthat never mentions@deriveat 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 samemix testinvocation 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, sotoken_typeis not caught bytoken. Twelve areDpExchange.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, shippedCredentialsstruct in this family has a field by that name andNotice's list did not cover it:app_key/app_secret(dp_exchange_webull) andclient_secret(dp_exchange_schwab).client_idis deliberately excluded even though Schwab's own struct redacts it too — an OAuthclient_idis 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 everydefstructin all five venues' actuallib/before the list was finalised: none collides with any of the twelveNotice-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, heldKeyword.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'sAuthmodule 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:credentialsfrom 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*.Credentialswith awrap/1function — encoding an implementation convention into a Core assertion the same wayAdapterContract'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 noInspectoverride at all, fails; the same struct with@derive {Inspect, except: [...]}added, passes; a hand-writtendefimpl Inspectthat 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 temporarypath:dependency,mix testrun, 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.exsgainsconsolidate_protocols: Mix.env() != :test: the new check's own test fixtures compile a struct'sInspectimplementation at test runtime viaCode.compile_string/2, aftermix 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 itsCredentialsmodule compiles as part of the ordinarymix compilepass, before consolidation runs.Assertion count is now nineteen.
usage-rules/testing.md,usage-rules/adapter.md(a dedicated section next to assertion 18's) anddocs/guides/building-an-exchange-package.mdupdated.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.
AdapterContractgains assertion 18, "link safety" — a process behaviour (GenServer,:gen_statem,GenStateMachine,WebSockex) that links a child it starts must also callProcess.flag(:trap_exit, true). Found live in four of five venue packages on 2026-09-07, all independently:Feed.init/1never trapped exits, andSocket.start_link/1(orPollingFeed.start_link/1) ran from inside aFeedcallback, which links the child toFeeditself rather than to a supervisor. An abnormal exit on that link was therefore untrappable and crashedFeed, and the venue'sSupervisorrestarted it from its static start opts — everysubscribe/2a 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_coinbasee77b542,dp_exchange_gemini66acd3b,dp_exchange_webulld0c54a8,dp_exchange_schwab90dddc6,dp_exchange_robinhood51ad189.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'sFeeddials its Streamer unconditionally frominit/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 (:sockethere, differently named or absent ondp_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.FeedBehaviourexists for exactly that and has zero adopters across the five.DpExchange.Core.LinkSafetyCheckinstead reads each candidate module's own compiled abstract code — the same:beam_libtechniqueCore.UnwiredCheckalready 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 callsProcess.flag(:trap_exit, true), so the invariant is checked against the module as a whole): a link-creating call (start_linkon any target,Process.link/1, orspawn_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/1andchild_spec/2are 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, compileddp_exchange_coinbase,Socket.start_link/1(use WebSockex) delegates toWebSockex.start_link/4to bootstrap itself, a call literally namedstart_linkon every process-behaviour module in the family, always — that link belongs to whoever callsSocket.start_link/1, not toSocket.Proven against a reconstructed pre-fix shape in
test/dp_exchange/core/ link_safety_check_test.exs(aGenServerlinking a socket fromhandle_call/3with notrap_exit, fails; the same shape withProcess.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 temporarypath:dependency,mix testrun, 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) anddocs/guides/building-an-exchange-package.mdupdated.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.
AdapterContractgains assertion 17, "credential gate" — on a venue declaringcredential_benefit: :required, no active credentialed endpoint'sfake: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 indp_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_differenceor:higher_ceilingmay legitimately serve some of these endpoints without a credential, and asserting a refusal there would invent a rule the venue never claimed.test_connection/2andget_rate_limit_status/2are excluded from the gate on any venue,:requiredor not — both callbacks documentcredentials() | nilon 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 settingcredential_benefit: :requiredonReferenceVenue(whoseget_balances/2ignores its credentials argument, correctly, for its real:higher_ceilingdeclaration) 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.exscarries the permanent regression fixtures (Broken.CredentialGate.NeverChecksand.Conforming).Assertion count is now seventeen.
usage-rules/testing.mdanddocs/guides/building-an-exchange-package.mdupdated;usage-rules/adapter.mdgains a dedicated section next to assertion 16's.Potentially breaking for any venue package declaring
credential_benefit: :requiredwhose fake does not already gate every credentialed endpoint on itscredentialsargument. Of the five, this specifically meansdp_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 declarecredential_benefit: :required(per this repo's own review of theircapabilities/0), so this assertion is inert for them today and only bites if one of them adopts:requiredwithout 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: :requiredvenue, 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 ofoptsinstead —get_option_chain/2,get_news/1,get_corporate_events/1andquantization/1are 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_webullanddp_exchange_schwabeach 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:
:requiredmeans every active endpoint needs a credential, so the assertion checks all of them —Venue.behaviour_info(:callbacks)minuschild_spec/1andstart_link/1(excluded via the sameanswerable?/1this suite already uses elsewhere; callingstart_linkon 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/2andget_rate_limit_status/2. Both are exempt for the same, unchanged reason — their own callback doc types the credentialcredentials() | 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/2and the rest of the streaming surface, which return a bare map or:ok/{:error, _}rather than aresult()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:optsposition, credentialed shape or not — the same[]endpoint_args/2already 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 readsKeyword.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 testrun, then reverted before anything was committed. Only the three venues declaringcredential_benefit: :requiredcan exercise this assertion at all:dp_exchange_schwab(495 tests) — 0 failures. Every newly-included endpoint, includingmarket_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:openalways, so nothing about this endpoint readsoptsor 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) anddp_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:requiredpath and nothing else in either package.market_status/1on 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'scredential_benefit: :requiredoverstates 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_webullanddp_exchange_robinhoodon their next Core bump, until each resolves themarket_status/1finding above. Not breaking fordp_exchange_schwab,dp_exchange_coinbaseordp_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/1finding left open by the widening above is resolved — not by adding it to@credential_gate_exempt.dp_exchange_schwab's realmarket_status/1calls an authenticated/marketsendpoint and its fake correctly refuses without a credential; a name-based exemption (the same mechanismtest_connection/2andget_rate_limit_status/2use) 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 onmarket_status/1exactly as before.Verified against the same three
:requiredvenues that exercised the widening, pointed at this Core checkout with a temporarypath:dependency,mix testrun, then reverted before anything was committed:dp_exchange_schwab— unaffected;asset_classes/0is not[:crypto], somarket_status/1is checked exactly as before, and its fake already passes.dp_exchange_robinhood— now passes without any code change on its side: itsasset_classes/0is[: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/0is[:crypto, :equity, :option, :future, :event_contract], not[:crypto], so the new exemption does not reach it andmarket_status/1remains 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.exspins both directions with fixture venues: a crypto-only:requiredvenue answeringmarket_status/1unconditionally must NOT be flagged, and the identical fixture serving one more asset class must be.DpExchange.Core.Venue'smarket_status/1doc andusage-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'sget_fees/2legitimately 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 atest_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 ondp_exchange_webull"fixed" the finding by gatingget_fees/2behind a credential it never used, reasoning that the real path "had never run throughAuth.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.Capabilitiesgainsno_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 shapeendpointsalready uses, so it cannot rot into one more hand-maintained name list the way@credentialeddid.no_venue_contact?/2reads it; assertion 17 now also skips an endpoint declared there. This is narrower thancredential_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 wrongcredential_benefitwould. Full argument inCapabilities's own moduledoc andAdapterContract's "17. credential gate" comment.Verified against all five venue packages, each pointed at this Core checkout with a temporary
path:dependency,mix testrun, then reverted before anything was committed:dp_exchange_coinbase,dp_exchange_gemini,dp_exchange_robinhoodanddp_exchange_schwabare unaffected (none declaresno_venue_contactand assertion 17 behaves exactly as before).dp_exchange_webullneeds its own fix —get_fees/2ungated on bothRestandFake, and{:get_fees, 2}added to itsno_venue_contactdeclaration — landed in that package's own repo.Teeth tests added to
contract_teeth_test.exspinning both directions with fixture venues: a:requiredvenue whose endpoint is declared inno_venue_contactand 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. TenProcess.sleep/1calls used purely as a wait-then- assert device are replaced withassert_receiveon a message the module already sends (on_notice, whichdelivering_nothing?/2fires on the very first failed tick for every single-symbol fixture these tests use, oron_refusal) or a synchronousPollingFeed.status/1/coverage/1call issued right after the event under test — aGenServer.callcannot 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. ThreeProcess.sleep/1calls are unchanged and were never a synchronisation device: twoProcess.sleep(:infinity)calls and oneProcess.sleep(20)are the simulated venue latency and hang under test, inside thefetchfunctions passed intoPollingFeed, not a wait on its output. Verified with no new flakes across eight consecutive runs plus three additional seeds. Nowait_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/0was missing1y, the same way it was once missing1wand1M.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 owntimespanenum), butCapabilities.new/1raised on1ythe way it used to raise on1w/1Mbefore 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 itshistorical_timeframesdeclaration with a comment naming this exact gap as a Core limitation rather than an under-declaration on its own part.@unbucketableis now~w(1w 1M 1y)— a year is not a fixed number of seconds any more than a month is, andseconds/1/aligned?/2/boundary/2treat it exactly as they already treat the other two: no boundary rule, never rejected as invalid.known/0is unaffected; onlynameable/0(and therefore whatCapabilities.new/1will accept inhistorical_timeframes) widens.Additive, not breaking: every existing valid
historical_timeframesdeclaration remains valid, sincenameable/0only grew. Unblocksdp_exchange_webulldeclaring its eleventh width — its@core_unnameable_widthsworkaround 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 incapabilities().endpoints— an endpoint never mentioned there at all slipped past it, even thoughCapabilities's own moduledoc makes an absent entry active too ("anything not named in the map is:experimental— the only honest default").Capabilities.endpoints_at/2iterates only the map's explicit entries by design (its own@docsays "every endpoint DECLARED at maturity"), soCapabilities.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}intoendpointsat 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 askingCapabilities.active?/2directly, rather than enumeratingendpoints_at/2's two lists.active?/2already 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.ReferenceVenuedeclares every single callback explicitly (seeendpoint_maturities/0), so Core's own conformance run is unaffected;Broken.SilentlyUnsupportedincontract_teeth_test.exsreproduces 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:unsupportedexplicitly, not to weaken the check.PollingFeed's:fetch_allpath crashed the whole feed process on a{:refused, _}return — the one outcomefetch_all_and_publish/1's case statement did not match — instead of recording one refused symbol.dp_exchange_robinhood'sFeedmoduledoc documented this exact gap as the reason it stayed on per-symbol:fetchrather than adopt this venue's own documented repeatable-query bulk endpoint (?symbol=BTC-USD& symbol=ETH-USDin one signed request): "a{:refused, _}returned from:fetch_alldoes not match either clausefetch_all_and_publish/1handles and would crash this feed's process instead of recording one refused symbol." Verified by reproducing it: afetch_allreturning{:refused, _}raisedCaseClauseErrorinsidehandle_info, taking the GenServer down.t:PollingFeed.fetch_all/0— previously undocumented as a type at all — now names a third outcome,{:refused, refusals}whererefusals :: [{symbol, reason}], the batch analogue offetch's own{:refused, reason}: each named symbol is reported once throughon_refusal, exactly as the per-symbol path already does, instead of being retried forever as an ordinary{:error, reason}would be.PollingFeed's:fetch_allpath 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 calleddelivering_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 noon_notice, the exact silent-failure shape this module's moduledoc names as the reason the escalation exists at all.{:ok, []}now routes throughrecord_failure/3with reason:empty_response, so it is counted, logged and escalated the same as any other empty cycle.record_success/2's now-unreachablefalseclause is removed; every remaining call site always delivered something, so it isrecord_success/1.HttpClient's retry backoff hardcoded4 - attempts_left, assuming the defaultretry_attemptsof 3.retry_attemptsis a documented, caller-configurable option; configuring it to 4 or more startsattempts_leftabove 4, so4 - attempts_leftgoes negative on the very first retry andProcess.sleep/1raisesFunctionClauseError— in the CALLING process, uncaught, since this library does not supervise its callers. The same failure shape this module's moduledoc already records forretry_attempts: nil(4 - nilvia Erlang term ordering), reachable here for a valid, in-range, documented integer instead. No existing test usedretry_attemptsabove 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>= 1regardless of configuration, and numerically identical to the old formula's own output at the default of 3.Notice.new/3validatedkindagainst the closed vocabulary but never validatedseverity, despiteseveritybeing 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 ownt:Notice.severity/0typespec.severityis now checked against[:info, :warning, :error], raisingArgumentErrorthe same way an unknownkindalready did.Notice.new/3read:severity,:atand:detailswithKeyword.get/3, which does not substitute its default for a PRESENT-and-nilvalue — the same trapDpExchange.Core.Config.opt/3,PollingFeedandHttpClienthave each paid for separately. A caller forwarding its own options (or computing a value and gettingnilback in an edge case) could produceseverity: nil(a struct violating its own typespec, previously unvalidated besides),at: nil(violating@enforce_keys' own non-nil promise, the same way aCore.Types.*decode bug does — seeTypes.Validate), ordetails: 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 throughDpExchange.Core.Config.opt/3: an explicitnilfalls back to the same default an absent key already used.Core.Types.Trade.new/1accepted an explicitbroken: nil, bypassing the struct's own documented default (false) and typespec (boolean(), neverboolean() | nil).:brokenis deliberately not@enforce_keys'd — an omitted value should default tofalse, and that part worked — but@enforce_keysguards presence, notnil(seeTypes.Validate), so a PRESENTbroken: nil(the shape a JSON decode produces from a venue field that came backnull) reached the struct unchanged.nilandfalseare both falsy in a bareif, which is exactly why nothing had noticed; acasematchingtrueandfalsewith no third clause does not get that courtesy, and:brokenis the field a phantom high or low rides in on.new/1now normalises an explicitniltofalse— this type's own moduledoc already saysfalsemeans "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_providerdefaulted tonilvia a baredefstructentry, 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_providerand an explicitby_provider: nilproduced%StakingBalance{by_provider: nil}, a value nothing downstream could safelyMap.get/2or iterate the way the typespec promises.defstructnow defaultsby_provider: %{}, andnew/1normalises an explicitnilto%{}the same way, consistent withTrade.broken's fix above.DpExchange.Core.Config.resolve_snapshot/3hardcodedApplication.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 asget/3does" — andget/3takesappas 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 bykey) 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 withput_override/2in 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/3is nowresolve_snapshot/4, takingappas its second argument (resolve_snapshot(snapshot, app, key, default)), matchingget(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/1built its struct with plainstruct!/2rather thanTypes.Validate.new!/3, so an explicitsymbol: nil—@enforce_keysguards presence, notnil— built an%Instrument{symbol: nil}violating its ownsymbol: String.t()typespec, the one field this whole type exists to attach base/quote/status/type to. EveryCore.Types.*struct already routes itsnew/1throughValidate.new!/3;Instrument(outside theCore.Types.*namespace, but carrying the identical@enforce_keys-guards-presence-not-nil shape) did not. Fixed to match the family convention.
Added
Core.AdapterContractgains assertion 16, "internal wiring" — every internal export must have a caller inside the package's ownlib/, 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_blockingplumbed throughCore.HttpClientbut never set by the caller (dp_exchange_robinhoodissue #16,dp_exchange_webullissue #23 — three separate option allowlists, a fix stopping at the first still passed every test asserting the keyword was present —dp_exchange_coinbaseissue #26);FrameSender's retry path indp_exchange_coinbase, reported but never retried (issue #22);dp_exchange_schwab'ssubscribe_notices/1facade, discardingopts[:to]instead of reachingFeed's notice registry;dp_exchange_schwab'sAuth.refresh/2, zero call sites inlib/whileSocketheld a token good for 30 minutes andwebsockexreconnected with no delay of its own.DpExchange.Core.UnwiredCheckis the engine: it reads:xref's real call graph (E, the same OTP tool assertion 7's purity check already readsimportschunks through), not a grep — a captured&Mod.fun/1and a literalapply(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:attributeschunk and that behaviour's ownbehaviour_info(:callbacks)—GenServer,WebSockex,Supervisor,DpExchange.Core.Venue, or any other),child_spec/1,child_spec/2andstart_link/1on every module regardless of declared behaviour, and every compiler-injected export. A default-argument function (def f(a, b \\ x), which compiles to bothf/1andf/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-fixFeed.subscribe/2andAuth.headers/1were 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_schwabat the commit before both fixes landed (c2f19b9, parent of09b8d1fandbf2e241), the check flagsAuth.refresh/2,Auth.needs_refresh?/2andFeed.subscribe_notices/2by 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 — mostlydef-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'sMqttPacket.disconnect/0andMqttPacket.subscribe/2, anddp_exchange_schwab'sAuth.needs_refresh?/2andStreamerProtocol.logout/2—needs_refresh?/2remains unwired even afterbf2e241, which wiredrefresh/2but not the function that was supposed to decide when to call it. Fixes are tracked separately, per venue.Documented in
usage-rules/adapter.mdnext to therate_limit_blockingsection it follows the same shape as.PollingFeedgains: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.PollingFeedalready 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. ALogger.warningis not a signal a supervising process can subscribe to.:on_noticeis an injected function, the same shape:on_refusalalready is, called with a%Core.Notice{kind: :coverage_change}the instant the feed crosses INTO the delivering-nothing state, and aseverity: :inforecovery notice the instant it crosses back OUT — a consumer that learns a feed died and never learns it recovered is only half-served.:coverage_changewas chosen over inventing a new kind: it is the same kinddp_exchange_coinbaseuses 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.detailscarries the feed'slabel, the consecutive failure count, and the last error — never a credential or a raw payload;Core.Notice.new/3refuses credential-shaped keys outright and would raise if it carried one.Defaults to a no-op, so every existing caller of
PollingFeed.start_link/1is unaffected. Wiring Robinhood's and Schwab's own feeds to fan this out to theirsubscribe_notices/1subscribers 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 splittingcoverage/1by data kind, per DpCryptoManagement's issue #22.coverage/1is correct and unchanged: it counts any payload for a symbol as delivering, aTypes.OrderBookexactly as much as aTypes.Quote. That is why Coinbase'slevel2channel delivering over 11,000 frames for 406 symbols whiletickerwas dark for all but 5 still reportedcoverage/1as:streamfor 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}. Seedocs/design/2026-09-05_coverage-by-data-kind.mdfor the fuller account, including why the consumer's own two proposed fixes (:subscribed_pending, adelivering/1companion) 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 existingdata_kind()vocabulary rather than inventing a parallel one, and is added toVenue.@optional_callbacksrequired 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/:gfmwiring behind a Core release before that.required_callbacks/0is unchanged;peripheral_endpoints/0classifies it irreplaceable and not load-bearing.AdapterContractgains assertion group 15, asserted only when a venue exports the callback (Code.ensure_loaded?/1thenfunction_exported?/3— the former is what stops the latter spuriously reportingfalsefor a merely-unloaded module): the union of symbols across every kind must equalcoverage/1's own key set exactly, and every kind key must be one the same venue's owncapabilities().streamabledeclares. An absent callback asserts nothing — a venue that has not adopted yet is not a failure, and the moduledoc says so in theifguard's own comment so nobody "fixes" it into a hard requirement later.ReferenceVenuedeliberately 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 incontract_teeth_test.exsreplicate the assertion's exact computation against a conforming fake and two deliberately broken ones (a union that drops a symbol, a kind not declared instreamable), 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/0already 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.mdandusage-rules.mdboth document the failure this callback exists to make visible, not only the callback's shape — a consuming agent reading either now learns thatcoverage/1alone 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", perdocs/design/2026-09-06_stop-maintaining-books-in-packages.md.Types.OrderBookis 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'sSocketdid this — a full book per symbol, measured at ~22,800 bid and ~21,100 ask levels forBTC-USDon a consumer's live node, rebuilt on everyl2_dataframe, inside a socket process that was starving its own:send_timeoutbecause 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.OrderBookDeltacarriessymbol,levels, the venue's owntimestamp, itssequencewhere it publishes one (nilwhere it does not, exactly asOrderBook's does) andprovider, with a validatingnew/1built onTypes.Validatelike every other type in the directory.levelsis[{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. Aquantityof 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 ownapply_book_row/2.This does not reintroduce the incident that made
Socketbuild a book in the first place — a caller reading onel2_datadelta 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_bookstays the rightdata_kind()for a delta stream — no new kind was added.coverage_by_kind/1answers "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 whatcoverage_by_kind/1was 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_upnotices bracket where the gap falls, and:sequenceon 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_upis to re-pullget_order_book/2and 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 fullOrderBookper delta and start passingOrderBookDeltastraight 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.mdanddocs/guides/building-an-exchange-package.mdboth said the conformance suite has fifteen assertion groups. It has had sixteen since assertion 16 ("internal wiring") landed, asassertions/0andAdapterContract'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 fivestreamablerows 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.mdshowed Coinbase as a blank row.transfer_internal/4is live there, and so arelist_payment_methods/2andget_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_previewcomment said only Schwab declares it. Coinbase and Webull declare it too.docs/guides/building-an-exchange-package.mdsaid no venue checked so far has a working sandbox. Gemini's does, andusage-rules/environments.mdhas said so, measured, since 2026-08-28.docs/reference/core/negative-claims.mdsaid 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-absentKeyword.gettrap, closed as a class rather than one incident at a time (C1).polling_feed.ex's:start_delay_msalready carried a fix and an incident comment; the same trap was open at every other default-bearing option inPollingFeed,HttpClientandDefaultRateLimiter— reachable because every venue forwards its ownoptsunchanged by family convention, so a key the caller never set arrives askey: nilrather than absent, andKeyword.get(opts, key, default)only substitutesdefaultfor an ABSENT key.interval_ms: nilcrashedProcess.send_after/3and restarted the feed straight into the same crash;on_refusal: nilraisedBadFunctionError;symbols: nilraised insideMapSet.new/1.HttpClient'sretry_attempts: nilwas worst: Erlang term ordering sortsnilabove every integer, sonil > 1istrue, and a forwardednilsilently entered the retry branch and died computing4 - nil— anArithmeticErrorraised 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-nilvalue is now treated the same as an absent one everywhere a default applies, and an explicitfalseis still honoured, becauseopt/3deliberately does not use||.PollingFeed— a hung fetch wedged the entire feed, silently (C2).fetch/fetch_allran synchronously insidehandle_infowith no timeout boundary;safely/1caught a raise or anexit, not a call that simply never returns. Verified with a fetcher doingProcess.sleep(:infinity):status/1andcoverage/1never 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, disposableTask(bounded_fetch/2,Task.async+Task.yield+Task.shutdown), and a hang past:fetch_timeout_msbecomes an ordinary fetch failure — retried next tick, counted towardfailures_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 aboveHttpClient'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: nilsilently disabled the wait ceiling (C3).acquire/3read:timeoutwith a plainKeyword.get/3, so a forwardedtimeout: nil— reachable fromHttpClient, whoselimiter_opts/1forwards:timeoutverbatim — producedwait_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 sameDpExchange.Core.Config.opt/3fix as C1, and asserted with its own regression test: an exhausted bucket withtimeout: nilnow refuses near-instantly (the refusal is decided on the server, before any sleep) rather than sleeping out a near-minute wait in the caller.HttpClientunder-recorded real venue usage (C4).record/3— the call that fills the bucketacquire/3andcheck/3measure 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 reachesmake_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_keysguarded presence, notnil(C5).%Candle{open: nil, high: ..., low: ..., close: ..., ...}built without complaint despiteopen's typespec declaringDecimal.t(), neverDecimal.t() | nil— exactly what a JSON decode bug on a renamed venue key produces, and the failure only surfaced later, deep insideDecimal, far from where the bad data entered. EveryTypes.*module now exposes a validatingnew/1, built on a new shared helper,DpExchange.Core.Types.Validate, that checks every field named in the module's own@enforce_keysfornilas well as presence and raisesArgumentErrornaming the offending field.Types.Orderis the one deliberate exception: its own moduledoc documents that six of its seven enforced keys legitimately admitnil("the venue's word, or nothing"), so itsnew/1narrows the check to:provideralone, the one field that was never meant to benil. Struct literals (%Candle{...}) are unchanged and remain valid for internal and test use;new/1is the path a venue's own decoder should prefer.CanonicalPairtrusted caller-supplied quote ordering (C6). The moduledoc requires a venue'squoteslist 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".quotesis now sorted by length, descending, insideCanonicalPairitself before any suffix match is attempted, so a caller cannot get the ordering wrong any more, whatever order it hands in.
Added
time_in_forcevocabulary extended with:gfwand: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 ofsupported_time_in_forceare unaffected. Robinhood could not use the new values until this shipped to Hex, so wiringRobinhood.to_order/1andorder_config/2was 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_core0.1.45 published these atoms, anddp_exchange_robinhoodnow decodes all four vendor values and raised its dependency floor to~> 0.1.45so it cannot compile against a Core lacking them.DpExchange.Core.FakeInjection— deterministic failure injection and a credential-free wiring mode for a venue'sFake— DpCryptoManagement's issue #14. None of the four venueFakes exposed aconfigure/1-shaped seam for exercising a consumer's own retry/circuit-breaker code, or a way to skip aFake's venue-faithful credential check to test pure dispatch/decode logic. Built onCore.Config's existing process-scoped override machinery rather than a new mechanism — the exactasync: trueisolation 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. Seedocs/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/0must 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. streamablenames 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/0says 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.
- Every absence has a recorded cause. An endpoint named in
Fixed
Notice.reject_credentials!/1could exhaust the VM's atom table from venue-derived input (C8). It normalised everydetailskey withString.to_atom/1before comparing it against the credential vocabulary. Atoms are never garbage collected and the atom table is finite;detailsmaps 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_keysonce, at compile time, and comparing every incoming key as a downcased string; no atom is ever created from caller input. SameDOS.BinToAtomclassCore.FakeInjectionwas already built to avoid. The raised error still names the offending keys exactly as before.PollingFeedcrashed when a caller forwardedstart_delay_ms: nil. Robinhood's and Schwab's ownFeedwrappers both build this option withKeyword.get(opts, :start_delay_ms)and no default of their own — a present key with anilvalue whenever their caller never set one.Keyword.get/3's own default only substitutes for an ABSENT key, not a present-and-nil one, sostate.start_delay_msended upniland crashed inProcess.send_after/3. Fixed at this layer with|| @default, so every venue'sFeedis covered rather than each patching its own pass-through.A stray zero-byte
lib/dp_exchange/x.newwas shipping in the tarball. It arrived as a redirect artefact incf03c21and had been published in every release since. Found by doing whatmix.exs's own comment block says to do — inspectingmix hex.buildoutput 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.mdstates 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 bygrant_type; Coinbase and Robinhood are Ed25519; Webull has two token systems, one of which returns200with 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: nilmeans 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.mdnow covers four pushing venues and one polling behind the same facade;symbols.mdcovers venues whose symbol is not a pair, where the work is refusal rather than transformation;testing.mdstates which of the four tiers each capability group can actually reach, and which cannot be reached at all;adapter.mdcovers 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 indocs/guides/, which is infiles:, and would have published inside a package whose whole premise is that it ships nothing venue-specific.README.mdstates what the contract covers — 87 callbacks by group — and indexes the seven guides.AGENTS.mdpoints at them.
Added
place_orders/3— several orders in one request, which closes OQ8.It is not
place_order/3in 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:symboland:idnow admitnil, 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
nilso 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/0is 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/3in 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/3andlist_custody_fees/2.get_payment_method/3exists 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 earlierlist_payment_methods/2result reads a status that may have been true an hour ago, and moving fiat against it is the failure that produces.get_notional_balances/3is notget_balances/2in 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 withget_balances/2; this is for reporting.list_custody_fees/2explains 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/3andget_transactions/2.transfer_internal/4is notwithdraw/5. Nothing leaves the venue, no chain is involved and no address is required. Conflating them is dangerous in both directions: a caller reaching forwithdraw/5for 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/4is 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.
detailsstays 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/2is wider than bothget_trade_history/2andget_transfers/2— fees, interest, dividends and adjustments alongside deposits and fills. Summing it is not a balance;get_balances/2is the authority and this is the explanation.list_networks/2andlist_fee_promos/1.list_networks/2is whatget_deposit_address/3needs 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
ethereumis another'sERC20— and normalising here would invent a vocabulary no venue accepts back.list_fee_promos/1is notget_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/3andTypes.FxRate. Gemini publishesGET /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
:sourceand:benchmarkare 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_ofis 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.Tradealready 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.Tradegains:broken, defaulting tofalse. 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/2excludes them unlessopts[:include_broken]says otherwise — hiding them entirely would conceal that the exchange made a correction.The moduledoc now also records what
:sidemeans: venues report the taker's side, so Gemini'sbuymeans 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/2andget_volume_profile/3, withTypes.AuctionImbalanceandTypes.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
Quotehas 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_sidewith 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.
:deltais 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/2returns 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 shapeget_orders/2uses 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 backnil— the venue did not publish them there, rather than the imbalance being zero.:event_contractin 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
:optionwould hand a caller a Greeks-shaped hole where the instrument has no Greeks.convert/4andget_trade_volume/2onVenue. Two more Gemini endpoints with no facade.convert/4is not a shorthand forquote_conversion/4pluscommit_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/4executes 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/2is the account's own volume, not the market's, and notget_trade_history/2summed. 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/2onVenue. Gemini publishes two bulk cancels and the family had no facade for either.opts[:scope]is required and has no default.:sessioncancels what this credential's session opened;:accountcancels 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/2pluscancel_order/3in 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-emptyrejectedis not a failed call — the venue answered and some orders were already gone.preview_replace/4andclose_position/3onVenue. 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/4is notpreview_order/3with 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 windowreplace_order/4exists to close.close_position/3is notget_positions/1plusplace_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 anOrder— 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'sside,order_type,quantityandstatusadmitnilin the typespec. They always could in practice — a venue sending a status this package does not recognise has producednilsince the beginning — and the typespec said otherwise, which meant dialyzer accepted the wrong thing and rejected the right one.Coinbase's
close_position/3is 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.Quoteno longer carries:bidand:ask. They are order book data — resting orders — andQuoteis trade data. Every venue package in the family was filling them, and one readprice || askfrom a best-bid/ask endpoint, producing a quote whosepricewas 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 callsget_price/2. Neither can stand in for the other.Core.Types.Quote's:timestampguarantee is unchanged and now load-bearing: the venue's own, used as-is. Observation time lives onTopOfBook.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_pricebecause it is the field most easily mistaken for a price),Types.OptionChain(two-dimensional, expiry → strike →{call, put}, a one-sided strike keepingnilrather than a missing key), andTypes.OrderLeg. Callbacksget_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 onQuote.:multiplierofnildoes 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/4returns[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.Candlenames 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?/1catches a malformed bar at the boundary.:volumeisnilwhen unpublished, never0.Types.Ordergains:time_in_forceand:legs.Capabilities.supported_time_in_forcedeclared 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:amountkept apart from:estimated_amount— a real response has them 40% apart) andTypes.ContractStats(mark and index are separate prices, and neither is a traded price), withget_funding/2andget_contract_stats/2.Conversions.
Types.Conversionplusquote_conversion/4,commit_conversion/2andget_conversion/2— the facade's only two-step write.:expires_atis the point: committing an expired quote can fill at the current rate, which looks like success.expired?/2returnsnilwhen no expiry was stated — unknown, not valid.Portfolios.
Types.Portfolioandlist_portfolios/1. A portfolio is an address, not a value; balances, orders and positions are addressed withportfolio: idinoptsrather than by adding a parameter to forty signatures.Money movement, write side.
Types.DepositAddress,Types.ApprovedAddress,Types.Withdrawal, andget_deposit_address/3,list_approved_addresses/1,estimate_withdrawal_fee/4,withdraw/5.withdraw/5is the only operation in this contract that cannot be undone. The allow-list is first-class:ApprovedAddress.usable?/2returnsnilfor 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_requiredis tri-state — a deposit missing a required memo is credited to nobody, sonilmust never be defaulted tofalse.:networkis enforced on both.Core.Types.Positionandget_positions/1— exposure, distinct from a balance and not derivable from one.:sideis explicit and:quantityalways 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_priceofnilmeans the venue did not say, not that the position is safe.data_kindgains:top_of_book,:candlesand: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_bookis deliberately not:order_book— venues stream them on separate channels because one carries a level and the other a book.t:data_kind/0records 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 ahas_stakingcapability 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— keepsstaked,available_to_tradeandavailable_for_withdrawalapart; a real response has the whole position redeemable and none of it tradable.by_provideris carried, not summed: a redemption is addressed to a provider.Types.StakingRate— percentages only,rate_pctandapy_pctboth named. One venue publishes basis points, a simple percentage and an APY for the same position;bps_to_pct/1lives here so the 100× conversion is done once.Types.StakingReward— carries its accrual period and the rate at accrual.Types.StakingTransaction— carries the unbonding progressionamount/amount_paid_so_far/amount_remaining.settled?/1returnsnilwhen the venue reports no progress — unknown, not complete.
Core.Types.TopOfBook— best bid and ask, with nopricefield.bid_sizeandask_sizeare optional (nilmeans not published, never zero);venue_timeis the venue's own ornil, since several BBO endpoints publish none;observed_atis required.mid/1,spread/1andcrossed?/1are 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/2on theVenuebehaviour, registered inperipheral_endpoints/0.Conformance assertion 14, "top of book is not a price" — asserts the returned struct is a
TopOfBook, thatobserved_atis set, thatvenue_timeis the venue's ornil, and thatTopOfBookhas nopricefield and cannot grow one.
Changed
preview_order/3andreplace_order/4are nowVenuecallbacks, 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 — andreplace_order/4is 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 declaressupports_order_preview: false/supports_order_replace: false. Once the host adopts these packages, adding a required callback would be breaking and would take the0.2.0seed §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.
ceilinggained an optional:scope(:credential | :account | :application), and:limitbecamenon_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 whileplace_order/3is:unsupported.catalog_access(:enumerable | :query_only) — whether the catalogue can be listed at all.:query_onlyraises ifget_symbols/1is:unsupported, because "searchable only" and "not served at all" are different facts.preview_order/3andreplace_order/4as required facade callbacks. Required rather than optional: the facade is one fixed set, and optionality is for ceremony. Both are peripheral, andreplace_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_accessmust match howget_symbols/1behaves without a query.
Documentation
usage-rules/adapter.mdnever mentionedDpExchange.Core.Config.opt/3,Types.<T>.new/1or the:gfw/:gfmaddition tosupported_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 currentsupported_order_typesandsupported_time_in_forcevocabularies, including:gfw/:gfmand why they were added; a "preferTypes.<T>.new/1" section carrying the same@enforce_keys-guards-presence-not-nilexplanation the code's own moduledoc gives, plus theTypes.Orderexception; and a section on the forwarded-optsnil-vs-absent trap namingDpExchange.Core.Config.opt/3as 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 ofdp_exchange_core,dp_exchange_coinbase,dp_exchange_gemini,dp_exchange_webull,dp_exchange_robinhoodanddp_exchange_schwabreturns200. Corrected to "published, experimental," with a line stating that publication is not proof of maturity — readcapabilities/0for that, not this table.Two stale assertion-count claims.
usage-rules/testing.mdsaid "Thirteen assertion groups";docs/guides/building-an-exchange-package.mdsaid "28 assertions." Neither matchesDpExchange.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
1wand1Mtoo.Capabilities.validate_history!/1was fixed in 0.1.10 to checkTimeframe.nameable/0, butAdapterContract's assertion 2 still checkedknown/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/0andTimeframe.nameable?/1— the widths Core can read as a label, which is deliberately wider thanknown/0, the widths it can bucket.1wand1Mare 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_leverageaccepts: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.nilwithsupports_margin: truestill raises, becausenilmeans "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 declaresupports_margin: false, which is false, or to invent a multiplier.
Fixed
Capabilitiesno longer refuses a venue that serves weekly or monthly candles.validate_history!/1checkedhistorical_timeframesagainstTimeframe.known(), which is the set Core can bucket — so declaring1wraised, even thoughTimeframealready documents both as deliberately unbucketable and instructs callers to read "no boundary rule" as "cannot check" rather than "invalid". Core contradicted itself:aligned?/2tolerates an unmodelled width,boundary/2passes it through, andCapabilitiesrejected it outright. A venue serving a real weekly candle had two options, under-declare or not ship. It now checksTimeframe.nameable/0; a width Core cannot name at all, such as3m, is still refused. Found deriving Schwab's declaration.Timeframenow models10m(600 seconds). Its absence was not neutral:aligned?/2returnstruefor a width it cannot model — "no rule" must not read as "invalid" — so every 10-minute candle passed the authenticity check unexamined, andboundary/2was a no-op on it. Found deriving Schwab's declaration, where/pricehistoryserves 1, 5, 10, 15 and 30-minute widths. Unlike1wand1M, 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-elementcasecrashed 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
HttpClientacceptsraw_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 namesInvalidSymbol,InvalidParameterValue. Without this a venue package has to recover the distinction by string-matching, andString.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.Capabilitiesceilings 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:burstmust 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.