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.1.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.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 reportingInspect.Any"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.