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. 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 —
what was run against the live venue, and when. "Marked proven" with no evidence is not an
acceptable changelog line.
[Unreleased]
[0.3.8] - 2026-09-11
Changed
dp_exchange_corefloor raised to~> 0.3.1. Core 0.3.0 deletedCore.DataProviderandCore.FeedBehaviour— two contracts with zero implementers, one of which was a second, competing definition of the venue interface carrying every shape this family has since fixed (prices as strings, providers as strings, balances with no timestamp, a single quote timestamp,{:error, String.t()}flattening the refusal/error distinction). A venue author who found it first would have built all of those, plausibly, and every one would have compiled.No code changes here: this package referenced neither module. The floor moves because a pin of
~> 0.2.8would not resolve 0.3.x — the pin doing its job, not a problem to route around — and because staying behind would leave this package on a Core that still ships the contradicting contract.Resolved and compiled against before the pin was written, per the rule this file's own dependency comment already records: a floor is only correct once it has been resolved, never once it has been reasoned about.
[0.3.7] - 2026-09-11
Added
This package now emits the
[:dp_exchange, :link, …]telemetry the contract has documented since it was written.Core.Telemetrysaid these are the events "every venue package emits"; there was not one:telemetry.execute/3call anywhere in the family for as long as the spec existed.:telemetry.attach/4against a name nobody emits succeeds — so a consumer wired a dashboard to it, got no error, and saw an empty panel, which reads as a venue with no traffic rather than as an unimplemented spec.:link, :upand:link, :downon the connection transitions, and:link, :eventper frame with its wire size. The request and rate-limit events come free withdp_exchange_core0.2.8, since every venue's REST goes throughCore.HttpClientand every metered call throughCore.DefaultRateLimiter.The metrics channel is alongside the notice channel, never instead of it. A
Core.Noticeis a condition a consumer must ACT on; telemetry is aggregate and lossy by design. A consumer that alarmed on a telemetry gauge would be acting on a channel documented as droppable, and one that graphed notices would be graphing something it is meant to handle.Two details worth stating, because both are places a plausible-looking number would have been wrong:
A frame is counted whether or not it parses. The question the event answers is "is the venue sending", and a frame this package could not read is still a frame the venue sent — counting only what parsed would make a decoder bug here look like a silent venue.
There is no
:link, :reconnect_attemptfrom this package. It reconnects immediately and keeps no attempt counter, so the only number it could report isattempt: 1, every time — which renders a reconnect loop as an endless series of first attempts. That is worse than no event.dp_exchange_schwabtrackslogin_failuresand does emit it.
Changed
dp_exchange_corefloor raised to~> 0.2.8, which is whereCore.Telemetry's emitter functions live. A venue calling:telemetry.execute/3directly would be naming events by hand in five places — five chances to write:link_upinstead of[:dp_exchange, :link, :up], with the drift invisible, since a wrong name emits successfully and simply never reaches a handler — and would be using a transitive dependency it never declared.
[0.3.6] - 2026-09-11
Added
Back-pressure: a slow subscriber no longer gets an unbounded mailbox.
Core.Venue'ssubscribe/2doc promised this from the day the contract was written, and no venue in this family implemented any of it — every one fanned out with a baresend/2and had never looked at a subscriber's mailbox. A consumer that stalled accumulated a mailbox until the node died, with no notice, no log line, andcoverage/1reporting perfect health throughout, because the feed genuinely was delivering.Past a bound (default 10,000 queued messages,
:max_queue_lenat start) this feed stops sending to that subscriber and emits a:degradednotice naming it, the queue length and the bound — and a secondseverity: :infonotice when it catches up. The pair brackets exactly the window a consumer has to reconcile from the pull endpoints.Implemented in
dp_exchange_core0.2.6 asCore.Fanout, shared rather than written five times. Three properties worth stating, because they are what make dropping acceptable at all: another subscriber that is keeping up is unaffected;coverage/1does not change, because it reports what the venue delivered to this package and not what this package forwarded; and notices are never subject to the bound, since the notice saying a subscriber is being dropped must not be the first casualty of that same subscriber being dropped.See
usage-rules.md, "A slow subscriber gets dropped, and told".
Changed
dp_exchange_corefloor raised to~> 0.2.6, and this one is hard.FeedcallsCore.Fanout.max_queue_len!/2atinit/1andCore.Fanout.deliver/4on every payload. Against a lower Core this package does not misbehave, it fails to compile — which is the good outcome.The pid-or-registered-name subscriber resolution moved to
Core.Fanout.resolve/1. All five venues had written it identically since DpCryptoManagement's issue #15; the data path and the notice path now share one definition, so they cannot drift into disagreeing about what counts as a reachable subscriber.
[0.3.5] - 2026-09-10
[0.3.4] - 2026-09-10
Fixed
coverage/1kept answering:streamfor symbols the dropped link had been delivering, and this package carried the written argument for why that was fine.socket.exsaiddeliveringis left alone because a symbol that was streaming is "reasonably still covered a moment ago" until the resubscribe timer revives it "or its own staleness ages it out of whatever freshness a caller applies downstream". That last clause was the one holding the argument up, and it was false:coverage/1returns%{symbol() => route()}and exposes no timestamp, so there is no freshness a caller can apply. The comment deferred to a mechanism that does not exist.Meanwhile
handle_disconnect/2returns{:reconnect, state}, so the socket process survives a transport drop and no:EXITever reachesisolate_crashed_shard/5— the one path that did clear delivery records. A reconnect that restored the socket while the venue silently failed to restore some symbols left those symbols reported as:streamindefinitely: the 325-subscribed/174-delivering incident this callback was written for.Socketnow reports which link dropped — beside the:link_downnotice, not inside it, because a socket pid is this package's wiring and has no business in aCore.Noticethat fans out to consumers — andFeednarrows coverage to exactly that shard's symbols and that shard's channel's kind. Alevel2drop does not erase a symbol's still-healthytickerquote, the same isolation a crash already got. The shard keeps its entry and its socket, because that socket is reconnecting rather than dead.dp_exchange_core0.2.5 writes the rule intoCore.Venue'scoverage/1doc — observation is scoped to the current transport session — and records why it cannot be carried by a conformance assertion. All four streaming venues in the family had this wrong in the same way and are fixed in the same batch.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.3.3] and earlier - 2026-09-10
Everything below this line is published. Entries were accumulated under
[Unreleased] from the first release to 0.3.3, 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.
Documentation
usage-rules.mdnow answers the question a consumer actually has after 0.2.0: when isvenue_timenilhere? The migration note said what the fields mean; it did not say what this venue does with them, which is the part a caller writes a branch for.Never, on this venue: every
QuoteandOrderBookparses a time the venue sent and fails closed when it cannot, so anilbranch here is dead code.
Changed — BREAKING
Core.Types.QuoteandCore.Types.OrderBookno longer carry:timestamp. They carry:venue_time(the venue's own,nilwhere the venue publishes none) and:observed_at(when this package read it, always present). Requiresdp_exchange_core ~> 0.2.1; this package's own version takes a minor bump to signal it.:timestampwas documented as the venue's own and "never invented", and two packages in this family could not keep that promise, because the frames they decode carry no venue time at all. With one field their only options were to lie or drop real data, and they lied.This venue always had a venue time to give, on every
QuoteandOrderBookit builds, so:venue_timecarries exactly what:timestampdid and:observed_atis new information rather than a replacement. Nothing this package reports became less precise.The full reasoning, the three options weighed and the consumer's own argument for this one are in
dp_exchange_core'sdocs/design/closed/2026-09-09_venue-time-and-observed-time.md, announced and answered as dp-exchange-core issue #31.Trade,Fill,BalanceandOrderBookDeltaare unchanged.
Added
:channels—level2can be opted out of (issue #1).active_channels/1hardcoded both channels and ignored its argument, so a consumer that reads quotes and routes order-book depth over REST had no way to decline the book and paid for it anyway.The measured cost, from the consumer who filed it: at 406 pairs and
@default_level2_pairs_per_socketof 30, 14level2sockets opened and maintained on top of the 5tickershards actually read — and 1,577,001OrderBookDeltaframes decoded and delivered in a single boot for a payload with no wired consumer. Ignoring them on receipt saved nothing: the sockets were open and the frames were parsed before delivery either way.children = [{DpExchange.Coinbase, credentials: creds, channels: [:quotes]}]The vocabulary is
capabilities().streamable's data kinds rather than this venue's channel strings, becausestreamable: [:quotes, :order_book]is what a consumer reads to decide — and it already read as though either could be requested alone. Omitting the option changes nothing, which is why the whole existing suite passed untouched.Two deliberate limits: the option narrows, never widens —
level2is authenticated here, so a credential-less feed still carriestickeralone whatever it asks for, rather than producing a doomed subscribe. And an empty list is refused atinit/1, because a feed subscribing to nothing reports permanent zero coverage, which is indistinguishable from a venue outage. An unknown kind fails there too, rather than being silently dropped.
Added
script/check_endpoint_inventory.sh— diffs the vendor's published Advanced Trade REST endpoint pages againstdocs/reference/coinbase/endpoints-enumerated.tsvweekly, via.github/workflows/inventory-check.yml. One HTTP request: this venue publishes no Advanced Trade specification, but itssitemap.xmllists one page per endpoint, so the set of those pages is the index.Deliberately not re-deriving the enumerated file. That file's method — reading each page's own
pageMetadata.openapifield — meant fetching 806 pages, which is a reasonable thing to do once by hand and a rude thing to do to a vendor every week.The mechanism is the one
dp_exchange_core's vendor-change design doc settled on: across five vendors a changelog diff caught nothing and an index diff was the only thing that ever fired. It has since found a rate-limit table ondeveloper.webull.comthat had existed for weeks behind a five-times-too-permissive ceiling, and a withdrawn WebSocket channel ondeveloper.gemini.com. Run against this venue it reports 51 endpoint pages, unchanged — a clean baseline, which is the other thing a check is for.
Documentation
"The vendor's rate-limit page could not be located" was a statement about the searcher, not the vendor — and it is now corrected with a real search behind it.
capabilities/0's ceiling provenance said the page could not be found. That was true of three URL guesses and false of Coinbase:docs.cdp.coinbase.com/sitemap.xmllists 2,357 pages, eleven of which are rate-limit pages, and they were listed there the whole time.All 84 Advanced Trade documentation pages were then fetched and searched. Exactly one carries any rate-limit text: the WebSocket page, at 8 per second per IP for connections and unauthenticated messages alike. So the honest claim is much stronger than the old one — there is no published Advanced Trade REST limit — and it is the claim worth re-testing when the docs change, rather than an admission of not having looked.
The ceilings themselves are unchanged and still rank 3: inherited from the prior adapter, not doc-derived (there is no document) and not measured (measuring a rate ceiling means deliberately exceeding a third party's). New reference file
docs/reference/coinbase/rest-rate-limits.mdrecords the method and the negative result so the next reader can re-run it rather than re-guess it.Why this was worth doing at all: the identical sitemap search on
developer.webull.comfound a per-endpoint rate-limit table that had existed for weeks while this family declared a ceiling five times too permissive there, on a venue whose documented penalty is a temporary IP block. "We could not find it" earns a second look.The 8-per-second-per-IP figure is now cited by URL and watched. It is load-bearing —
Feed's@shard_spacing_floor_msof 125 ms is derived straight from it — and it now names the page it comes from and has a row indoc-sources.tsv, so a change to it is caught by the weekly check rather than by someone re-reading the comment.
Fixed
Reads now carry
@call_timeoutexplicitly, exactly as writes already did.coverage/1,coverage_by_kind/1,status/1andwanted/1tookGenServer.call/2's implicit five seconds while every write named a generous one — the same asymmetry that turned a bounded delay into a dead caller in issue #28. Second line of defence, never the fix: a read that has to queue behind something should wait for it, not die of it.The alias-catalogue fetch blocked every read on this Feed — dp-exchange-core issue #28's failure, on this venue.
handle_info(:fetch_alias_map, …)read the venue's whole/market/productscatalogue over HTTP inline, and withCore.HttpClient's documented defaults (30_000 ms per attempt, 3 attempts) that blocked this GenServer for up to about ninety seconds.coverage/1andcoverage_by_kind/1are plainGenServer.call/2s on the five-second default, so a health check landing during the fetch did not wait, it exited, taking a consumer that reads it from its ownhandle_call/3with it.Found by sweeping for the class rather than by it failing here — the #30 reporter named the shape ("work done in the process that owes a reply") while describing something else, and this family has paid for it three times already (#16, #23, #28).
The fetch now runs in a task and its result arrives as a message. A second
:fetch_alias_maptick while one is in flight is dropped rather than starting a second catalogue read: two would race to writestate.alias_map, with the loser silently overwriting the winner, and would double a request this venue's limiter is sized for one of. Exceptions are converted inside the task, becauseTask.async/1links and an unconverted raise would arrive as an{:EXIT, …}with no clause for it — leaving the in-flight marker pinned and every later tick dropped forever.
Documentation
Credentials' moduledoc now says that the redaction wrap lives inchild_spec/1, and that bypassingchild_spec/1bypasses it. Requested by the consumer who verified the dp-exchange-core #29 fix and then went looking for their canary in their own supervisor's state — and found it. Their supervision code builds the child spec itself (start: {__MODULE__, :start_feed, [module, opts, pairs]}) for a legitimate reason: aCore.PollingFeed-shaped facade defaultssubscribertoself(), which resolves to the supervisor whenstart_link/1is called frominit/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 exactly as before. Upgrading does not fix it, because nothing from this package is on that path.No code change:
wrap/1andwrap_opt/1were already public, which was all that path needed. What was missing was anyone saying so — the natural assumption, "upgraded, therefore redacted", is wrong there, and assertion 22 cannot see it because it asks aboutchild_spec/1's own rendering.dp_exchange_core'susage-rules/auth.mdcarries the full version, including the reshaping case that bit them: a host mapping its own key names into a venue's and returning a bare map re-introduces the leak in its own code, downstream of anything a package can reach.
Fixed
Credentials were written to the log in cleartext by any crash — dp-exchange-core 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.:credentialsarrived as a plain map, so every crash printed the live secret in full. It needs no unusual conditions, it lands in ordinary application logs — the artifact most likely to be shipped to an aggregator or attached to a bug report — and it defeats credential hygiene upstream of it: a consumer can hold the key encrypted at rest and still have it written out in the clear. The reporting consumer found live keys this way and nearly pasted them into a GitHub issue while reporting a different bug.child_spec/1now wraps:credentialswithDpExchange.Coinbase.Credentials.wrap_opt/1, and the placement is the fix: wrapping instart_link/1orinit/1does nothing, because by then the supervisor above has already captured the raw list. Redacting the value rather than setting the:sensitiveprocess flag is deliberate — that flag suppresses the whole report, including the stack trace that made the unrelated bug diagnosable. This keeps the report and removes only the secret.dp_exchange_core's conformance suite gains assertion 22 for exactly this, so it cannot come back here or arrive in a new venue.
Added
script/check_doc_sources.shanddocs/reference/coinbase/doc-sources.tsv— a weekly, non-blocking check that every vendor documentation page this package cites still resolves the way it did when a person read it. It records status and redirect destination and does not follow redirects or diff content: a permanent redirect is itself the change notice (this family lost a streaming API to one, announced by nothing else), while content diffing a rendered docs site would be red every week for reasons that are never the reason we care about. Built after auditing what would have caught each way five vendors' documentation turned out to be wrong — across that whole sample a changelog diff caught nothing, and an index diff was the only mechanism that ever fired. It earned itself immediately: on its first run indp_exchange_webullit caught a cited page that 404s, and pulling that thread found a rate ceiling five times too permissive against that venue's own per-endpoint table. Scheduled Mondays 09:20 UTC via.github/workflows/doc-sources-check.yml, never on push, never in the publish chain. Documentation sites only — never a venue API, which tier 2's never-on-a-schedule rule still forbids.Three pages are tracked here, including the candles endpoint behind the
FOUR_HOURincident — a width the venue served and the adapter silently substituted1hfor.
Changed
@default_shard_spacing_ms:5_000→1_000— closesdocs/design/ideas/shard-spacing-headroom.md, moved todocs/design/closed/2026-09-09_shard-spacing-headroom.mdwith this outcome. The old value was inherited, unexamined, from the reference fix this package replaced — never derived from anything about this venue, the same way@pairs_per_socket(100) still is. Coinbase's own Advanced Trade rate-limits page documents WebSocket connections at 8 per second per IP, converting directly to a floor ofceil(1_000 / 8)=125ms;5_000ms was roughly forty times more conservative than that floor required.1_000ms is one connection per second — an 8x margin under the documented floor, not the floor itself (500ms, a 4x margin, was also considered and set aside for the larger one). The extra margin beyond the documented rate alone is deliberate: Webull's shards crash-looped this same week once abandoned sessions accumulated against an undocumented five-connection ceiling on that venue, and Coinbase documents a rate, not a concurrency cap — no concurrency cap is documented for it either, but "no documented cap" is not "no cap." At1_000ms, only a handful of this module's own connects are ever simultaneously mid-handshake for the 406-symbol/19-shard scopefeed.ex's own moduledoc discusses, against roughly two dozen that would be in flight at once at the bare125ms floor. Reasoned entirely from Coinbase's own documented rate-limits page — no live probe was run, per this repo's own testing-tier rules for a pure connect-rate number that already converts to an exact floor with nothing left to bisect.All four call sites that read
shard_spacing_mswere traced before choosing: the initial connect stagger andretry_missing_shards/1's reopen stagger both open brand-new sockets, a direct fit for the documented floor; the unconditional 60-second resubscribe walk staggers frames onto already-open sockets, kept on the same number both because Coinbase's own rate-limits page states its 8/second/IP ceiling covers connects and messages together and becauseSocket.subscribe/4blocks theFeedGenServerregardless of what the venue does with the frame;next_resubscribe_delay/1is a derived floor that has to track whatever the resubscribe walk actually uses. One number continues to do all four jobs — seefeed.ex's own moduledoc, "one spacing, several jobs," for the full reasoning kept alongside the code.For the 406-symbol scope, boot-to-full-
level2-coverage drops from 90 seconds at the old default to 18 seconds at this one (a fivefold improvement — the 90-second figure itself corrects an earlier moduledoc approximation,(14 - 1) * shard_spacing_ms, that omitted the 5tickershards staggered ahead oflevel2in the same sequence; the true span is(shard_count - 1) * shard_spacing_msover all 19 touched shards, verified againstreshard/1's actual position math). This is a behaviour change for any consumer that has not setshard_spacing_msexplicitly: this feed now reaches full coverage noticeably faster after boot and after any event that reopens shards. Passshard_spacing_ms: 5_000to keep the old, more conservative pacing.
Fixed
Auth.jwt/2's two-minute CDP token expiry (now + 120) had no citation anywhere — correct, but unlabelled, in a file where every other numeric venue claim carries one. Found by a family-wide sweep for the@pairs_per_socket/@shard_spacing_msdefect class this package's ownFeedmoduledoc already documents. Coinbase's own JWT Authentication page states "your JWT is only valid for a period of 2 minutes," and separately that this is the SDK samples' default rather than a server-enforced ceiling — nowdocs/reference/coinbase/jwt-auth.md, cited fromAuth's own@doc. No value changed.BREAKING:
capabilities/0declaredhas_staking: false(the default — the field was never set) whilestake/3andunstake/3were already:experimentaland genuinely reach Coinbase Prime. Found bydp_exchange_core's conformance-coverage audit, which added a cross-check (Capabilities.new/1, assertion 2 in the shared suite) requiringhas_stakingto agree with the six staking endpoints it summarises — the same rule already applied tosupports_order_preview/supports_order_replace. Against Core 0.1.71 this package's own contract suite failed 23 of 42 tests, every one of them the sameArgumentErrorraised insidecapabilities/0itself, not 23 independent defects. Confirmed rather than assumed which side was wrong:DpExchange.Coinbase.Prime's nine endpoints are real paths againstapi.prime.coinbase.com, signed with Prime's own HMAC scheme, reached fromstake/3,unstake/3and this module's ownquery_transaction_validators/3,claim_rewards/4,staking_status/4,unstake_status/4andpreview_unstake_wallet/6— not documentation describing a capability nobody wired up.has_stakingis nowtrue, at the same:experimentalmaturity as the rest of this:0.xpackage: the paths are read from Prime's own documentation, not probed live — this repository holds no Prime credential, and D7 tier 4 (money-moving) is answered in production by a consumer, never by a test here.measured_againstsays so explicitly. This is a behaviour change for a consumer routing onhas_staking: it used to readfalseand now readstrue, honestly, for a venue that has always been able to move money into and out of a staked position through this package.get_staking_rates/1,get_staking_balances/1,get_staking_rewards/1andget_staking_history/1stay:unsupportedinvenue_does_not_serve/0— that reasoning is about their own shape (no published rate schedule, a status endpoint naming one wallet rather than every position, a rewards claim being a write not a report, no history endpoint at either scope) andhas_stakingbecomingtruedoes not reopen it. The same defect class already fixed indp_exchange_gemini(has_staking: false/supports_margin: falsebeside six staking and three margin endpoints already:experimental).BREAKING: three defects in
level2's unsubscribe-before-subscribe reconcile, all found by re-tracing the mechanism as a whole rather than as the sequence of fixes that built it, none caught by the existing suite. (1) A vanishing shard's stranded unsubscribe could be recorded and discarded in the samereshard/1call —drop_unwanted_shards/3droppedstate.pending_unsubscribes[key]unconditionally for every shard no longer wanted, including onestrand_unsubscribe/7had just populated moments earlier, or one an in-flight deferred reconcile would populate a moment later — silently contradicting the notice text's own promise that it would be "picked back up on the next unconditional resubscribe cycle." (2) An ORDINARY reconcile (subscribe/2,unsubscribe/2,update_symbols/2) never consultedstate.pending_unsubscribesat all — only the 60-second:resubscribetick did — so a shard carrying one unconfirmed release could accept a plain additive subscribe for an unrelated new symbol on the very next ordinary call, the "quiet overflow" DpCryptoManagement's own probe 2 describes, reached through the path this whole design exists to close. (3)isolate_crashed_shard/5's reopen and an ordinary reconcile's own recovery of the same now-missing shard key could race: the deferred{:open_shard, _, _}handler always opened a fresh socket unconditionally, so whichever attempt'sput_inran last wonstate.shards[key]'s slot and the other's socket — still alive, still linked, still holding a live venue subscription — was leaked, never referenced by this module again.drop_unwanted_shards/4now only drops a vanishing shard whose release is confirmed clear;reconcile_shard_in_place/8foldspending_unsubscribesinto what it treats as "current" on every reconcile, not only the 60-second cycle;handle_info({:open_shard, _, _}, _)now no-ops if the key already exists rather than opening a second socket. Breaking in the narrow sense that a shard whose vanishing release is still pending, or whose newly-added symbol was withheld behind one, now behaves differently (correctly) than before — no public API changed.A crash of
FeedorSocketprinted the CDPapi_key/api_secretpair in cleartext, in OTP's own crash report. Both processes hold:credentialsfor their entire lifetime —Feedto keep resharding and resubscribing,Socketto sign every authenticatedsubscribe/4— and both stored it as a bare map field inGenServer/WebSockexstate. OTP's default crash report prints a process's state in full on termination; a plain map prints every key it holds, secrets included. Verified by crashing an equivalent process holding%{api_key: "...", api_secret: "..."}as a bare state field and reading the resulting log line back — the key pair came back in cleartext.Process.flag(:sensitive, true)was tried as an alternative and does not help: the same crash, with the flag set, printed the same cleartext state; it disables tracing, not crash-report formatting. Now both processes wrap the pair inDpExchange.Coinbase.Credentials, a struct whoseInspectis derived withexcept:naming both fields, at the point credentials enter state — nowhere else in either module changes, because a struct is a map andAuth.jwt/2's%{api_key: k, api_secret: s} = credentialsstill binds the real values inside the one function that has to sign with them. Re-verified against a real crash of the new shape: the log line now readscredentials: #DpExchange.Coinbase.Credentials<...>. SeeCredentials's moduledoc for the full mechanism, including why it also closes a second leak (aFunctionClauseError's printed argument list goes through the sameInspectprotocol as a crash report's state).A shard's socket crashing took the whole
Feeddown with it, silently discarding every subscription this feed had ever been given.Socket.start_link/1runs insideFeed's ownhandle_call/handle_info(get_socket/1), which links every shard's socket toFeedthe waystart_linkalways does.Feednever calledProcess.flag(:trap_exit, true), so an abnormal socket exit — a decode bug raising inside a WebSockex callback, or anything else that kills the socket pid — sent an untrappableEXITsignal along that link and crashedFeedtoo.DpExchange. Coinbase.Supervisorthen restartedFeedfrom the staticoptsit was given at tree-start, which never carry a consumer's latersubscribe/2calls: one shard's bug cost every symbol this feed was ever asked for, not just the shard that broke. Found by a 2026-09-07 supervision audit — proven by linking a real process into a runningFeedthe wayget_socket/1does and killing it withProcess.exit(pid, :kill)(not:normal, which a non-trapping process ignores), which crashedFeedbefore this fix and does not after.Feednow traps exits and isolates a crashed socket to the one shard it belonged to: only that shard's symbols lose coverage, only thedata_kind()that shard's channel carries is cleared fromcoverage/1/coverage_by_kind/1(alevel2crash no longer erases a symbol's still-healthytickerquote), a:link_downCore.Noticereports the crash, and the shard reopens immediately rather than waiting out the next:resubscribetick (up to 60s by default). Every other shard, on other sockets, is untouched.BREAKING:
supported_order_typesandsupported_time_in_forcewere both[]while{:place_order, 3}is:experimentalandRest.order_configuration/1's own@configurationscross-product builds three order types across four time-in-force values.Capabilities.new/1validates the contents of these lists but never that a venue with an activeplace_order/3declared anything, so two empty lists passed every check. Nowsupported_order_types: [:market, :limit, :stop_limit]andsupported_time_in_force: [:ioc, :fok, :gtc, :gtd]— read directly off@configurations, not invented.:stopand:post_onlyare deliberately absent: the venue's cross-product has no:stopentry (only:stop_limit) and Advanced Trade's order endpoint has no post-only flag. Found by a cross-package audit;dp_exchange_robinhooddefaulted the same two fields the same way for the same reason.child_spec/1did not declaretype: :supervisor, so OTP defaulted it to:worker— which also defaults:shutdownto5_000ms instead of:infinity. A consumer terminating this child gave the whole nested tree (socket shards, rate limiter, and everything under them) only five seconds to shut down gracefully before:kill, rather than letting it unwind on its own terms. Invisible to any single-package review, and found only by diffingchild_spec/1across all five venue packages against each other;dp_exchange_schwabwas the only one that already declared it.get_top_of_book/2answered a missing local credential with{:refused, :missing_credentials}— in the realRestclient and inFakealike — even though the credential never left this process and nothing at Coinbase ever saw a request to decline.DpExchange.Core.Venue's own moduledoc reserves:refusedfor the venue's own permanent word about a request it actually received; a locally-detected precondition is an:error. Found by a cross-package audit: Gemini, Robinhood and Schwab's real facades already used{:error, {:missing_credentials, venue}}for this exact condition — this package and Schwab'sFake(see that package's own changelog) were the two hold-outs. Now{:error, {:missing_credentials, :coinbase}}, matching the rest of the family.A credential that could not sign produced an unauthenticated request that was actually sent — on write endpoints,
place_order/3included.Auth.rest_headers/4was written asCore.HttpClient's 4-arity auth hook, which may only return a header list and has no way to say "abort, do not send". So on a signing failure it returned the content-type header alone and documented that "the caller decides whether an unauthenticated request is acceptable". No caller ever decided:Rest.request/5andRest.json_request/5both handed the result straight toHttpClient.request/5without checking whether anAuthorizationheader came back.For a public market-data
GETthat was harmless — Coinbase serves those anonymously. Forjson_request/5there is no public path: every caller of it is a write, so a malformedapi_secretturned a live order into an unauthenticated POST that the venue answered with an opaque401, which reads as a credential problem at Coinbase rather than a malformed key here. That is precisely the failure the function's own moduledoc existed to prevent, produced by the mechanism documenting it.Auth.rest_headers/4now returns{:ok, headers} | {:error, reason}and both request paths gate on it withwith, the way every other venue package in this family already gated on its ownAuth.headers. Thenil-credentials branch is unchanged and is not this case: a call made deliberately without credentials still takes the public path.The test suite was green throughout, because the fixtures used an
api_secretof"-----BEGIN EC PRIVATE KEY-----"— unparseable, and therefore never signing anything. Every fixture now carries a real 32-byte Ed25519 seed, so the suite exercises the signing path it was previously bypassing.Auth.jwt/2raisedKeyErroron credentials it could not read, rather than refusing by name. It readcredentials.api_keyunconditionally, sonil,%{}or a map assembled with a typo'd key crashed inside signing — reachable from every write endpoint throughRest.json_request/5, which carries nonilguard of its own, and fromFeed's unattended alias-map fetch. It now answers{:error, {:missing_credentials, :coinbase}}, the shape the other four venue packages in this family return for the same condition.4xx statuses were recovered by searching the error message for
"404", which was wrong in both directions.Core.HttpClient's own moduledoc names that exact expression as the reason itsraw_status: trueoption exists:- False positive — any 4xx whose body contained
"404"(an order id, a price, an embedded vendor code) became{:refused, :not_listed}: permanent, never retried, for something that may have been transient. - False negative — a
400,401or403contains no"404", so a bad request or a rejected credential fell through to{:error, message}and read as possibly-transient. A rotated key was retried instead of refused.
Restnow passesraw_status: trueand matches the status exactly, as the other four venue packages already did.404remains{:refused, :not_listed};400/401/403are now{:refused, {:venue_error, status, message}}carrying the venue's own words.One existing test asserted
{:error, _}for a rejected credential while its own name — andtest_connection/2's moduledoc — both said refusal. Both were right; the assertion was pinning the bug.- False positive — any 4xx whose body contained
Changed
capabilities/0now declaresauthenticated_streamable: [:order_book]. It was left at its[]default, which reads as "no streamed kind here needs a credential". That is not true:Socket's@authenticated_channelsnameslevel2, andFeedsubscribes["ticker"]without credentials and["ticker", "level2"]with them — so an anonymous consumer gets quotes and no book. A host asking whether it needed a credential before it could stream book data was told no, and would have learned otherwise from a book stream that never arrived.
Added
level2_pairs_per_socketis now a supervision option, not only the internal@default_level2_pairs_per_socketconstant it defaults to:children = [{DpExchange.Coinbase, credentials: my_credentials(), level2_pairs_per_socket: 25}]Default is unchanged — 30, DpCryptoManagement's own live bisection against the real venue on 2026-09-06 (
n = 6/12/25/30accepted,n = 31/35/50/100refused; adopted as the constant in9139881) — the measurement is not being re-opened, only made adjustable. A value below1, or a non-integer, failsFeed.start_link/1at start with anArgumentErrorrather than being coerced. A value above30is honoured, not capped — with aLogger.warningnaming the measured ceiling, its date, and the concrete risk: an oversizedlevel2subscribe is refused by the venue WHOLESALE, losing that shard's entire coverage — and the socket SURVIVES the refusal (measured 2026-09-07, see the "corrected claim" entry below), which makes it quieter than a disconnect, not louder: no link-down, no reconnect, just a shard that silently never delivers. Capping it would have quietly defeated the option's own purpose, which is letting a consumer absorb a venue-side ceiling change without a package release; this repo has no way to verify such a change itself (tier-3, authenticated, live probing is out of scope for this repo — see the testing strategy). The default is deliberately not shrunk for headroom:30is the actual, located boundary, not a value merely bounded from below, and a consumer wanting margin below it can pass a smaller value themselves.ticker's own 100-per-socket size gets no equivalent option — it has no known ceiling to tune against. Seefeed.ex's own moduledoc,"level2_pairs_per_socket — a supervision option", andusage-rules.mdfor the full reasoning (DistortionPoint/dp-exchange-coreissue #22).shard_spacing_msis now a supervision option, not only the internal@default_shard_spacing_msconstant it defaults to — the delay between opening each successive shard's socket, whichever channel it carries, and the same delayreconcile_shard/7now applies when reconciling several already-open shards in oneupdate_symbols/2call:children = [{DpExchange.Coinbase, credentials: my_credentials(), shard_spacing_ms: 1_000}]Default is unchanged — 5,000ms, inherited from the reference fix this package replaced, the same way
@pairs_per_socket(100) is. A negative value, or a non-integer, failsFeed.start_link/1at start with anArgumentError, the same shape aslevel2_pairs_per_socket;0is NOT refused —Process.send_after/3accepts it without complaint, so there is nothing mathematically broken about it, only extreme (a connect burst, the exact hazard this whole file otherwise staggers to avoid).Coinbase's own Advanced Trade rate-limits page states WebSocket connections are limited to 8 per second per IP, which converts directly into a floor of
125ms (ceil(1_000 / 8)) on this package's own connects. A value below that floor is honoured, not refused — this package cannot verify whether a faster pace is safe for a given consumer's own network position — but it logs aLogger.warningnaming the documented floor, its source, and the concrete risk: connects tighter than the venue's own stated per-IP rate risk the connect-burst resets this package's own moduledoc opens with. Seefeed.ex's own moduledoc,"shard_spacing_ms — a supervision option", for the full reasoning, including why the 5,000ms default is left unmoved here even though it is roughly forty times more conservative than the documented floor requires (docs/design/ideas/shard-spacing-headroom.mdrecords that as a non-blocking discovery, not acted on).This option exists first for this package's own test suite: five sharding tests in
feed_test.exsexisted to prove staggering happened and could previously only do that by waiting out the real production delay — 45 of the suite's roughly 51 seconds, across five tests. They now inject a small value and prove the same relative ordering and that a real delay was applied, rather than sleeping through the production default.
Changed
BREAKING:
Socketno longer maintains alevel2order book. Anupdateframe now deliversdp_exchange_core's newTypes.OrderBookDelta, not a fullTypes.OrderBook. A consumer that used to receive a rebuiltTypes.OrderBookon everyl2_dataframe — including a single-rowupdate— now receives aTypes.OrderBookonly onsnapshot(once per subscribe/resubscribe) and aTypes.OrderBookDeltaon everyupdate: the venue's own changed rows, in the venue's own order, both sides interleaved exactly as the frame carried them, in a flatlevels :: [{side, price, quantity}]list rather than split intobid_levels/ask_levels. A consumer wanting a maintained book now builds and holds it itself. This is not presented as a performance improvement — it is the removal of state this package was never supposed to hold. Seedp_exchange_core'sdocs/design/closed/2026-09-06_stop-maintaining-books-in-packages.md.Why: holding the book cost 65–110 ms per delta at the book size DpCryptoManagement measured live for
BTC-USD(~22,800 bid / ~21,100 ask levels, issue #22), later optimised to ~6.6 ms — but that work ran on the same single-threaded process responsible forWebSockex.send_frame/2, so a socket busy rebuilding a book it was never asked to keep could not service its own sends, which is the:send_timeoutbehind issue #22. Maintaining state this package was not supposed to hold is what broke the connections it was supposed to keep; this change removes the work rather than making it faster a second time.A
quantityof zero still means the level ceased to exist, not a price of zero — carried through completely unresolved now, since resolving it would itself be state-keeping.Reconnect reconciliation is now the consumer's job, not this package's. A dropped and resumed connection does not promise the deltas after it are contiguous with the deltas before it.
subscribe_notices/1's existing:link_down/:link_uppair brackets where a gap may fall; neither that notice nor anything else reconstructs a missing delta. The correct response to:link_upis to re-pullget_order_book/2(unaffected by this change) or accept the venue's own freshsnapshoton resubscribe — not to keep applying deltas across a gap. Coinbase'slevel2channel publishes no book sequence number, so:sequenceon both types is alwaysnilhere.Socket'sbooksstate,apply_book_event/3,apply_book_row/2,update_level/4,remove_level/3,deliver_book/3andprice_key/1are all gone, along withbench/order_book_resort.exs, which benchmarked work that no longer exists.Feed.payload_kind/1now maps%Types.OrderBookDelta{}to:order_book, the samedata_kind()a full%Types.OrderBook{}gets —coverage_by_kind/1answers "is book data arriving", not "in what shape", and the struct type itself already tells a caller which shape it is holding.Also dropped, deliberately, as part of the same change: the exact-scaled-integer precision check
price_key/1used to enforce (refusing a price with more than 8 decimal digits) existed only to support the:gb_treesordering key that mechanism needed — it was never an independent business rule. Sorting a snapshot's own rows viaDecimal.compare/2needs no such key and has no rounding step to guard against, so a price at any precision the venue sends now passes through unchanged, the same as every other decimal field this module decodes. Likewise, two numerically-equal, differently-scaled prices in one snapshot ("1.5"and"1.50") are no longer folded into one last-write-wins level — that folding was an accidental side effect of the old map-keyed implementation's own key, never a documented venue behaviour (unlike the 8-decimalquote_incrementfinding, this had no live measurement behind it), and silently choosing a winner between two rows is itself the kind of substitution this family refuses. Both rows now pass through as the venue sent them.
Added
Five of Coinbase Prime's nine staking endpoints are now reachable from the facade —
dp_exchange_core's new "internal wiring" conformance assertion (assertion 16) caught them as built and never called from this package's ownlib/.DpExchange.Coinbase.query_transaction_validators/3,staking_status/4,unstake_status/4,claim_rewards/4andpreview_unstake_wallet/6now delegate straight to the matchingDpExchange.Coinbase.Primefunction, the same patternstake/3andunstake/3already used for the other four. None of these map onto adp_exchange_core.Venuecallback —staking_statusanswers a narrower question thanget_staking_balances/1would,claim_rewardsis a write where that callback wants a read, andquery_transaction_validators/preview_unstake_wallethave no generic analogue at all — so they are Coinbase-specific facade functions, the same shape as the existing futures and portfolio extras (list_futures_positions/1,get_portfolio_breakdown/3, and friends).venue_does_not_serve/0's own documentation already claimed these were "reachable asPrime.X"; that claim is now true through the facade as well, not only by reaching past it into an internal module.coverage_by_kind/1implemented — Coinbase is the motivating case fordp_exchange_core0.1.48's new optional callback.coverage/1answers "is anything arriving for this symbol" by counting any payload at all, so alevel2book update counted identically to atickerquote. That blindness is not hypothetical:level2delivered upward of 11,000 frames across 406 subscribed symbols whiletickerstayed dark on all but a handful, andcoverage/1still answered:streamfor all 406 — correct by its own definition, and exactly why DpCryptoManagement's issues #20 and #22 stayed unpinned for days.Feed'sdeliveringmap now tracks%{symbol => %{kind => timestamp}}instead of a bare timestamp, keyed off whichCore.Typesstruct actually arrived (%Types.Quote{}→:quotes,%Types.OrderBook{}→:order_book) — never off this venue's own channel names, which stay internal.coverage_by_kind/1onDpExchange.CoinbaseandDpExchange.Coinbase.Fakeboth satisfy the union invariantdp_exchange_core's conformance suite now checks whenever a venue exports this callback: the symbol keys across every kind exactly matchcoverage/1's own keys, and every kind reported is onecapabilities().streamabledeclares. The fake reports everything under:quotesonly, honestly —subscribe/2never synthesises an order book, and claiming:order_bookcoverage it cannot back would be the "differently capable" divergence this fake's own moduledoc forbids.Bumped
dp_exchange_corefrom~> 0.1.36to~> 0.1.48to pick up the callback.Fakewired toCore.FakeInjection— DpCryptoManagement's issue #14. Every function with a real success path (not an unconditionalVenue.not_supported()) now checks a queued or always-set outcome first:get_price/2,get_top_of_book/2,get_historical_prices/4,get_order_book/2,get_trades/2,quantization/1andclose_position/3support per-symbol targeting; every other real function (bulk reads, account/portfolio/conversion calls, order placement, cancel/get/list, previews and edits) supports whole-call injection.subscribe/2,unsubscribe/2andupdate_symbols/2are deliberately not wired — each takes a symbol list in one call, which whole-call injection cannot express partial failure for; neither iscoverage/1orsubscribe_notices/1, both local bookkeeping that always succeeds by construction.No credential-bypass mode here. Unlike
DpExchange.Robinhood.Fake(the reference implementation), this fake has no central credential check to bypass — most functions never inspectcredentialsat all, an existing gap this wiring does not change. Seedocs/design/2026-09-04_webull-sharding-and-fake-injection.md§3.6/§3.7 indp-exchange-core.get_market_overview/1andlist_instruments/1are implemented — DpCryptoManagement's issue #10. Both sat behindVenue.not_supported(), one filed as a genuine venue absence (@venue_does_not_serve) with no per-item comment explaining why, againstget_symbols/1already calling the exact bulk endpoint (/products//market/products) that carries all of it. Live-verified: the response Coinbase actually returns namesprice,price_percentage_change_24h,volume_24h,high_24h,low_24h,statusandproduct_typeper product, andget_symbols/1kept onlyproduct_id. Both new functions read the same responseget_symbols/1already fetches — via a sharedfetch_products/1— rather than a second request.
Removed
Feed.pairs_per_socket/0andSymbolFormat.mapping/0deleted — both were public getters over a module attribute with no caller anywhere in this package's ownlib/, the other shape assertion 16 exists to catch.Feed's own moduledoc already says sharding details "never reach the facade" —@pairs_per_socketis consulted directly byshards/1, the function that actually shards, and stays; the accessor was consulted only by a test asserting the same numbershards/1's own tests already prove through behaviour.SymbolFormat.mapping/0's doc claimed it existed "so the conformance suite can driveCanonicalPairwith it," butdp_exchange_core's conformance suite calls onlyto_canonical_symbol/1andto_exchange_symbol/1(the actual behaviour callbacks) — it never calledmapping/0, and neither did anything else outside this package's own test suite. Breaking, for the two functions removed; neither was part of theVenuebehaviour or documented as consumer-facing inusage-rules.md.
Fixed
Corrected claim: a single oversized
level2subscribe does not close the socket. It is refused wholesale, and the socket survives — which is worse to detect, not better. Same issue #22. Thelevel2_pairs_per_socketwarning anddocs/reference/coinbase/level2-session-limit.mdused to leave this open: the 2026-08-26 incident recorded the refusal closing the socket, but the 2026-09-07 cumulative-overage probe (see the entry below) showed a refusal that did NOT close it — and nobody had re-run the single-oversized shape since 2026-08-26 to know whether that distinction mattered. DpCryptoManagement ran it, 2026-09-07: one socket,Socket.subscribe/4forn = 30(accepted,books=30), thenn = 60andn = 120(both REFUSED —rate_limited,books=0, nothing delivering), in both ascending and largest-first order;Process.alive?/1stayedtruethrough a 40-second drain after every refusal, no:DOWNon a monitor, either ordering.The refusal is wholesale (the whole shard's coverage is lost, not truncated to the first 30) and the socket survives (no disconnect at all) — the identical shape the cumulative-overage probe already showed, so the two readings this package could not choose between turn out to be the same behaviour. The 2026-08-26 incident record is kept, dated, as unexplained rather than overturned: this measurement could not reproduce it, and neither this package nor DpCryptoManagement knows why. The risk is restated as more dangerous to notice, not less: a closed socket announces itself through a disconnect and a reconnect; a refusal on a socket that stays alive announces nothing beyond the
:rate_limitedCore.NoticeSocket'serror_kind/1already emits — liveness looks perfect, and the oversized shard simply never delivers.coverage/1/coverage_by_kind/1are the only things that reveal it, unchanged by this correction: both already report only symbols that actually delivered a payload.The consumer's first attempt at this probe hit a harness defect worth recording for future probing with this package's own
Socket: tearing a socket down withProcess.exit(socket, :normal)is ignored by a process not trapping exits, so "closed" sockets stayed alive and leaked into the next attempt's count; fixing that to:killthen killed the probe itself, becauseSocket.start_link/1links the socket to its caller —Process.unlink/1first was needed. Recorded, dated and attributed, indocs/reference/coinbase/level2-session-limit.md.lib/dp_exchange/coinbase/feed.ex's moduledoc (thelevel2_pairs_per_socketsection and itsvalidate_level2_pairs_per_socket!/1warning text),usage-rules.md, anddocs/reference/coinbase/level2-session-limit.mdare all updated; no code behaviour changed, only the documented and logged claim. No newCore.Noticewas added:Socket's existing:rate_limitednotice already names the venue's own refusal message verbatim, which already carries this specific cause — a second notice would duplicate one already firing.Socketfull-re-sorted BOTH sides of the maintained level2 book on EVERYl2_dataframe, including anupdatechanging a single price level —dp_exchange_core'sdocs/design/2026-09-06_order-book-resort-cost.md. Measured at the book size DpCryptoManagement reported live forBTC-USD(issue #22, ~22,800 bid / ~21,100 ask levels): one update frame cost 62–110 ms across repeated runs in this repo (bench/order_book_resort.exs) — a ceiling of roughly 9–16 book updates/second, maximum, on a socket a shard shares across up to 100 symbols. Stated as a hypothesis in the design, not a conclusion here: this is very likely a major part of whytickerstarves wheneverlevel2is delivering broadly in #22, since the single-threaded socket could never idle long enough to service thetickersubscribe insideFrameSender's 5-second window — but that causal claim is only confirmed by this fix changing behaviour on their node, not by anything measured here.Each side's
%{Decimal => Decimal}map is now a:gb_treestree keyed by the price scaled to an exact integer (10^8— verified 2026-09-06 against Coinbase's own publicGET /api/v3/brokerage/market/products: the smallest publishedquote_incrementacross all 931 products is0.00000001, 8 decimal places, and no product's ownpricefield carries more precision than that either), carrying the originalDecimalas the tree's value. Delivery is now an ordered traversal —Enum.sort_by/3no longer runs anywhere in the per-frame path. Measured against the same book size, repeated runs in this repo: the same one-update-frame cost fell to 0.9–2.9 ms (roughly 345–1075 updates/second, maximum), and the isolated bids-only sort/traversal fell from 49–83 ms to 0.5–6.2 ms. Runmix run bench/order_book_resort.exsto reproduce on any machine.A price that cannot be represented exactly at that scale is refused and reported through the same
:data_qualitynotice path as any other unparseable row, rather than rounded — no real Coinbase price has needed this path so far, but the family's own rule against silently substituting a nearby value applies here too. One behaviour is deliberately NOT identical to the map-keyed implementation: two numerically-equal, differently-scaled price strings ("1.5"and"1.50") used to become two map entries — two "levels" at one price, because%Decimal{}structs compare unequal by field even whenDecimal.equal?/2says they are the same number — and now collapse into one, last-write-wins. That was a latent defect in the map-keyed version, found and fixed here rather than a behaviour changed as a side effect; everything else observable — order,Decimalvalues, count, theCore.Types.OrderBookstruct itself — is unchanged, and is asserted so against a reference reimplementation of the old approach insocket_test.exs.The alias-map fetch added for issue #22 was throttled by the caller's OWN rate limiter at boot and never retried, disabling attribution for the life of the process — DpCryptoManagement's issue #26, a regression in a fix this package shipped.
Feed's defaultalias_map_sourcecalledRest.get_alias_map/1withoutrate_limit_blocking: true, so it went through fail-fastcheck/3instead of blockingacquire/3. The fetch is scheduled off the firstsubscribe/3, which for any real consumer is boot — the single most contended moment for their own limiter (universe discovery, catalogue reads and market overviews all landing at once) — so it was scheduled into exactly the window most likely to throttle it. One throttled call there, on code that never retried, was permanent:state.alias_mapstayed%{}for the life of the process. Measured live: 406 pairs requested as-USDC, delivered as-USD, overlap 5 —coverage_by_kind/1and the consumer's own tracker each reporting a truthful, and wildly different, count, precisely the situation the issue #22 fix existed to end.This is the third instance of one family-wide pattern —
dp_exchange_robinhood's issue #16, this package's own issue #23 sweep, and now this: a background call with nothing waiting on it, failing instead of waiting, whileCore.HttpClient's own error message names the fix in its text ("callers that can wait should setrate_limit_blocking: true"). The issue #23 sweep audited every REST call site in this package and missed this one, because the alias-map fetch's own HTTP path was mistaken for its sibling WebSocket resubscribe path, which genuinely has no rate-limited replay to default. That recurrence is worth more than any one of the three individual fixes: the same shape of gap keeps landing at the one call site nobody thought to re-check.Three changes, all in
Feed:rate_limit_blocking: trueis now set unconditionally on the fetch, by a newdefault_alias_map_source/2that also forwards:limiter,:plug,:timeout,:retry_attempts,:retry_delayand:weightfromstart_link/1's ownopts— the same allowlist shapeRest's own request pipeline uses.- Blocking removes the self-throttle as a failure mode but not every failure: the
limiter's own bounded wait can still time out.
transient_alias_map_failure?/1classifies that one case as worth retrying — the identical request can reasonably succeed once the bucket has drained further — and treats everything else (an unrecognised response shape, a refused request, an unclassified reason) as permanent, matchingtransient_subscribe_failure?/1's own default-to-permanent stance one section up. Retries are bounded (@max_alias_map_retries, backed off by@alias_map_retry_delay_ms, both overridable for a test's benefit) rather than looped forever. - The degraded-attribution notice was unreceivable by construction, also part of
issue #26. The fetch is scheduled from
subscribe/3; a consumer callingsubscribe_notices/1afterward — the ordinary sequence — could register only after the fetch had already failed and fanned out to zero subscribers. A notice announcing a persistent degraded state that can only ever fire in the one window before anyone could be listening is worse than no signal: it looks like a working alarm that never rings. Fixed by replay, not by moving the emission earlier (this file already learned that moving-the-window lesson once, withnext_resubscribe_delay/1's per-tick storm):alias_map_statusand the reason that produced it now persist in state, andsubscribe_notices/1replays the identical notice to a newly-registered subscriber whenever it finds the state already:unavailable— once per new registration, never on a timer, and never re-sent to a subscriber who already has it.
New tests in
feed_test.exsdrive the real fetch pipeline end-to-end for the first fix — a real, namedCore.DefaultRateLimiterwith its sole token already spent, behind a fake:plugresponse, provingrate_limit_blocking: trueactually reachesCore.HttpClientrather than merely surviving being typed into an allowlist — plus the transient-retries-and-succeeds, permanent-fails-once, bounded-exhaustion and late-notice-subscriber cases, all against the defaultalias_map_sourcecodepath or a controlled stand-in, none against a guessedProcess.sleep/1duration.An empty
pricebooksarray from/best_bid_askwas read as the venue naming a product not listed, and there is no evidence this venue has ever said that this way — audited alongside DpCryptoManagement's issue #25 (dp_exchange_robinhood's confirmed instance of the same substitution).get_top_of_book/2's{"pricebooks" => []}clause turned a 200 with an empty array into a permanent{:refused, :not_listed}— permanent becauseCore.PollingFeedreports a refusal once and never retries it.Probed live 2026-09-06 against the closely related, unauthenticated
/market/product_book(same pricebook data, one product per call instead of a batch): a product this venue has never listed answers404 {"error":"NOT_FOUND","error_details": "valid product_id is required"}; a product it delisted but still recognises (/market/products/{id}still answers 200) answers a different404 {"error":"NOT_FOUND","error_details":"no pricebook found"}. Neither is a 200 with an empty array, and no online product checked (923 listed, spanning the lowest-volume pairs) ever returned one either. This venue's own convention for "no book" is a distinguishable non-2xx statement./best_bid_asktakes a list ofproduct_idsand answers one pricebook per product it can — an ordinary batch-API shape is to omit an entry it cannot answer rather than fail the whole request, which collapses "never listed" and "listed but delisted" (two states the sibling endpoint tells apart) into one indistinguishable silence, and says nothing about a real, momentarily bookless product either.The empty-array clause now returns
{:error, :empty_result}— retryable, the same shape a 500 already produces. A genuine venue statement (a 404, this venue's own convention) still reaches{:refused, :not_listed}through the existingclassify/1path, which this change does not touch. New tests inrest_test.exsandorder_book_test.exscover both: an empty array is retried, and a genuine 404 is still refused.A timed-out channel subscribe was logged and thrown away — no retry until the next 60s tick reproduced the identical failure, DpCryptoManagement's issue #22.
FrameSender's own moduledoc says the whole point of turning asend_frameexit into{:error, :send_timeout}is that a slow socket becomes "a failed batch, which a caller can report and retry" — the retry half of that design was never wired intoFeed. Alevel2subscribe triggers a full per-symbol book snapshot; the socket is single-threaded and cannot service the nextsend_framewhile decoding it, so firingticker's subscribe@channel_spacing_mslater still landed inside that window on a 100-symbol shard and blew the hardcoded 5s send window. Dropped, forever, since nothing re-attempted it before the next resubscribe cycle recreated the same busy socket.Measured live across a real ~400-symbol consumer, five boots over roughly 5.5 hours — the exact inversion this predicts:
state quotes ( ticker)order_book ( level2)broken (4 boots) ~5 / 406 ~406 / 406, 11,000+ frames healthy (1 boot) 400 / 406 6 / 406 When
level2got through broadly, its opening snapshot burst starvedticker; when the venue refused mostlevel2subscriptions outright (its own per-session stream limit — see the "sharded" section above),tickerhad the socket to itself and got everything. A lone:send_timeouton atickersubscribe was also observed directly in an earlier run.{:error, :send_timeout}and{:error, {:send_exit, reason}}are now retried — transient, since the identical request can reasonably succeed once a busy or briefly gone socket catches up.{:error, {:credentials_required, channel}}is not: no amount of waiting supplies a credential that was never given, and it fails loudly on the first attempt instead of looping. The backoff reuses@channel_spacing_msrather than a second, independently guessed number for the same busy-socket wait, bounded to two retries (three attempts total) — the whole chain resolves in at most 24s, well inside even the 60s default resubscribe cycle, so it can never stack fresh frames against the unconditional re-issue. Exhausting the retries, and the permanent-error path, both now emit aCore.Noticeof kind:coverage_changein addition to the existing log — a channel that never subscribed is exactly the invisible half-dead feed this issue is about, and aLogger.warningalone gave a consumer no facade-level way to see it. A socket that dies between attempts is re-checked, not assumed alive, and simply stops the chain rather than sending into a corpse.Deliberately unchanged:
@channelsorder (level2beforeticker) and@channel_spacing_msitself. Subscribing the lighter channel first is a plausible additional fix, but it is unmeasured and changing two things at once would make the next measurement uninterpretable — raised separately with the consumer instead.:rate_limit_blockingwas unreachable on every REST call this package makes — family-wide gap, DpCryptoManagement's issue #23.Core.HttpClient.check_rate_limits/1reads this option to chooseacquire/3(wait for capacity) over fail-fastcheck/3, and its own error message on a self-inflicted throttle tells a caller to set it — but no caller could, on this venue:Rest.request/5,Rest.json_request/5andPrime.request_opts/1all stripped it from their forwarded-options allowlist before it ever reachedCore.HttpClient. The same defect (dp_exchange_webull's issue #23,dp_exchange_robinhood's issue #16) audited across the rest of the family; this venue was one of four still carrying it.All three allowlists now forward
:rate_limit_blocking, proven with a recording rate limiter that records which ofacquire/3/check/3was actually called — not merely that the keyword survives the allowlist. Not defaulted anywhere in this package, unlikedp_exchange_webull'sFeedanddp_exchange_robinhood'sFeed: this venue's own periodic resubscribe (DpExchange.Coinbase.Feed's unconditional 60s re-issue) sends WebSocket frames, not HTTP, so there is no rate-limited background replay here to justify choosing a default on a caller's behalf. A caller that wants blocking opts in explicitly.A resubscribe interval shorter than one re-issue cycle wedged the feed — including the 60s DEFAULT, past twelve shards. A cycle is not instantaneous: shards are staggered
@shard_spacing_msapart and each shard's channels@channel_spacing_msapart, so the last frame goes out about(shards - 1) * 5_000 + 8_000ms after the tick. If the timer re-fired before that, cycles overlapped, frames queued behind each other,WebSockex.send_frame/2blew its window, and theFeedstopped answering calls entirely —:sys.get_state/1timing out. A wedged feed is strictly worse than a late resubscribe.Found by DpCryptoManagement while running the diagnostic added in the previous release (issue #22): they set
resubscribe_interval_ms: 5_000, below the 8s channel spacing, and lost the run to it. They reported it against themselves rather than against the option, which is how it got looked at properly — because checking it showed the same failure was reachable with no option set at all. The 60s default is shorter than the cycle span from twelve shards (1,101 symbols at@pairs_per_socket) upward, so a large enough consumer would have walked into it on defaults alone. The knob exposed a limit the default already had.The next delay is now derived from the shard count that actually exists at each tick, never from the configured value alone, and an extension is logged rather than applied silently — a diagnostic knob whose value is quietly ignored is its own trap. Nothing changes for any interval that was already comfortable.
get_top_of_book/2could never work without credentials, and the facade said otherwise — family-wide defect sweep, Coinbase B1. Unlike every sibling market-data reader inRest, this one call is hardcoded to/best_bid_askwith no/market/best_bid_askbranch. Re-verified live 2026-09-05: authenticated is401, and the public path a caller would expect by analogy is404— there is no public form to fall back to, so inventing one would have been exactly the "nearby substitute" this family refuses. Fixed by checking for credentials up front and returning{:refused, :missing_credentials}before sending anything, rather than surfacing the venue's401as an opaque error.DpExchange.Coinbase's moduledoc andcapabilities/0'scredential_benefitcomment both claimed "the same market data is served publicly" without qualification — true of every other endpoint, false of this one — and both now name the exception.usage-rules.mdcarried the identical claim and is corrected the same way, since it ships inside the Hex tarball and is what a consuming agent reads.apply_book_row/2silently dropped alevel2row it could not parse — family-wide defect sweep, Coinbase B2. Every other decode-failure path inSocket(deliver_ticker/3,deliver_book/3) reports a:data_qualitynotice throughreport_quality/2; this one returned the maintained book unchanged with no signal, against the module's own stated discipline ("a payload that did not parse is reported, not swallowed and not fatal"). Concrete cost:new_quantity: "0"is how the venue signals level removal, so an unparseable quantity silently ignored could leave a stale price level in the maintained book indefinitely with nothing indicating why.apply_book_row/3now threadsstatethrough and reports a:data_qualitynotice for an unparseableprice_level/new_quantityand for a row missing those keys entirely — the connection is still never torn down over one bad row.Socket.start_link/1inherited WebSockex's own connect/recv timeouts by accident — family-wide defect sweep, Coinbase B4. No:socket_connect_timeoutor:socket_recv_timeoutwas set, so WebSockex supplied its own defaults — measured in the vendored dependency,deps/websockex/lib/websockex/conn.ex:10-11:6_000ms connect,5_000ms recv. That matters specifically becauseFeed'sopen_shard/5synchronous branch callsSocket.start_link/1from inside ahandle_call/3, andFeed's own@call_timeoutis@frame_window_ms * 3=15_000ms — a named, shared process, so every other consumer'ssubscribe/2,unsubscribe/2,update_symbols/2andcoverage/1call queues behind that one call. The inherited defaults alone (6_000 + 5_000 = 11_000ms) would burn roughly three-quarters of that budget on the TCP connect and the handshake recv alone, against an unreachable or black-holing venue, before a single subscribe frame is sent. The margin was never chosen; it was whatever the dependency happened to default to.Fixed by setting both explicitly at
3_000ms each (6_000ms total), chosen deliberately againstFeed's15_000ms budget — leaving roughly9_000ms of the same call for the socket to send at least one subscribe frame (capped atFeed's own5_000ms@frame_window_ms) plus ordinaryGenServeroverhead. No failure semantics changed:start_link/1still returns{:error, reason}synchronously exactly as before, so the synchronous-primary-shard design is unchanged bit for bit — only the margin after a slow or absent venue does. A caller passing either key inoptsstill overrides it. The merge is factored into a small@doc falseconnection_opts/1so a regression test can pin both the defaults and the override precedence without opening a real socket.The venue rewrites an aliased product id on delivery, and streaming passed the rewritten id straight through — DpCryptoManagement's issue #22. Measured live 2026-09-05 against
wss://advanced-trade-ws.coinbase.com: subscribingtickerto["XLM-USDC", "AVAX-USDC"]— sent exactly as asked, both real, listed products — delivers every frame taggedXLM-USDandAVAX-USDinstead; the venue's own subscription acknowledgement even echoes the rewritten names back ("ticker" => ["XLM-USD", "AVAX-USD"]), not the ones actually sent. This is the venue's own declared behaviour, not a guess: the same public, unauthenticated/market/productscatalogue this package already reads forget_symbols/1andlist_instruments/1names it directly — on this date, 112 of the first 114 USDC products carried a non-emptyaliasnaming their-USDcounterpart. On a settled DpCryptoManagement node running 0.1.17 with 406 pairs requested, this was the same defect wearing two faces: 174 of the 406 requested pairs delivered nothing under the name asked for, while 401 pairs never requested were decoded and stored under a name nobody subscribed.Fixed in
Feed, notSocket:Socketstill decodes and delivers under whateverproduct_idthe venue actually sent, unchanged.Feed.handle_info({:dp_exchange, :coinbase, payload}, state)now resolves a delivered id againstRest.get_alias_map/1— the venue's own declared relationship, fetched once, asynchronously, the first timesubscribe/3orupdate_symbols/2runs (never per frame, never per subscribe; seeFeed's moduledoc for why it is not read frominit/1or inline in the triggering call) — and delivers under every name inwantedthat names the same market: the caller's own requested name, and its alias where the caller subscribed to that instead. A caller subscribed to both receives both, from one delivered frame.coverage/1needed no code change to become honest, since it already reports whatever key delivery is recorded under.A catalogue that cannot be fetched degrades rather than guesses. A failed fetch delivers under the venue's own id — today's pre-fix behaviour — and reports exactly once, as a
:data_qualitynotice naming the failure, that attribution is degraded and why. Munging-USDCinto-USDwas considered and rejected: it would be exactly the "nearby substitute" this family forbids, and wrong for any pair the venue does not alias — nothing here assumes the suffix relationship holds in general, and the fix reads only the venue's ownaliasfield.Regression tests in
feed_test.exsdrive the proven mechanism directly — a subscribe to the alias form receiving frames tagged with the canonical form delivers under the alias form;coverage/1lists what was requested; both names subscribed both receive one delivered frame; a catalogue fetch failure delivers under the venue's id plus the degraded notice, never a guessed mapping; the fetch happens once regardless of how many subscribes or delivered frames follow — andrest_test.exscoversget_alias_map/1itself against a catalogue shaped like the live response captured while proving this.Supervisor's own rate limiter was configured frompublic_ceilingunconditionally, contradictingcapabilities/0's own documented promise that credentials buy the higher ceiling.capabilities/0states outright: "Pass credentials and this package uses the authenticated path, which has the higher ceiling."Rest's request paths honour that — butSupervisor'sinit/1startedDefaultRateLimiterfromcaps.public_ceiling(3 req/s) for every instance, credentialed or not, so a consumer supplying credentials got the documented 10 req/s from the venue and a third of that from this package's own throttle regardless — a mechanism silently disagreeing with the declaration it exists to encode, per this module's own moduledoc rule.limits/1now readsopts[:credentials](the same optsFeedreads it from) and configures the bucket fromauthenticated_ceilingwhen a non-empty credential map was given,public_ceilingotherwise. New tests insupervisor_test.exsprove both ceilings actually reach the running limiter, and that an empty%{}does not buy the higher one.A shard whose socket failed to open at all was silent at the facade — no
Core.Notice, only aLogger.warningthat never crosses it. Every shard beyond the first opens asynchronously; when itsSocket.start_link/1failed (a transient connect refusal, a timeout),handle_info({:open_shard, _, _}, _)logged and stopped, leaving those symbols silently absent fromcoverage/1with nothing telling a consumer why — the exact "silent half-dead feed" this module's own moduledoc is about, and worse than the channel-subscribe case one section up, which already got a:coverage_changenotice for the identical shape of fact (subscribed intent that did not become delivery). Now emits one, through the samenotify_shard_open_failed/3path.Worse: a shard that failed to open had no automatic recovery path at all. The unconditional
:resubscribetick only ever walkedstate.shards— a shard whose socket never opened is not a key in it, so the tick had nothing to re-issue for it, and the only thing that would ever reconsider it was a freshsubscribe/3orupdate_symbols/2call, which may never come for a consumer whose scope is stable after boot.retry_missing_shards/1closes this: on every:resubscribetick, any shardstate.wantedstill implies butstate.shardshas no entry for is retried on the same unconditional cadence an already-open shard's subscriptions are re-issued on, staggered past them by the usual@shard_spacing_ms. New tests infeed_test.exscover both: the notice on a failed open, and the tick recovering a shard that never opened.Reconciling more than one ALREADY-OPEN shard in a single
update_symbols/2call dropped the stagger between them — only a brand-new shard's connect was staggered.reshard/1computesposition * @shard_spacing_msfor every shard beyond the synchronous primary and hands it totouch_shard/4, butreconcile_shard/6(an existing, already-open shard) never received it — every already-open shard a single call touched had itslevel2subscribe scheduled at the identical instant regardless of position. This is not the connect burst@shard_spacing_mswas written against (no new socket opens here), but a related hazard:Socket.subscribe/4blocks THISFeedprocess — viaFrameSender, up toWebSockex.send_frame/2's 5s window — for as long as its target socket takes to acknowledge, and several such messages landing in this process's single mailbox together serialise into back-to-back blocking sends, capable of stallingcoverage/1and every other call to thisFeedfor as long as the slowest one takes.reconcile_shard/7now receives and applies the samedelayopen_shard/5already did. A new test infeed_test.exsproves it with two live sockets recording arrival time: touching two already-open shards in one call now delivers their first frames@shard_spacing_msapart, not together.Fake'sget_top_of_book/2ignoredopts[:credentials]entirely, making the fake MORE capable than the real venue on the one call where that gap matters.Rest.get_top_of_book/2refuses{:refused, :missing_credentials}before sending anything when given none —/best_bid_askhas no public form on this venue, confirmed live (401authenticated,404at the/market/...path a caller would expect). The fake answered{:ok, %Types.TopOfBook{}}regardless, so a consumer's test written without credentials would pass against the fake and refuse identically in production — precisely the silent "differently capable" divergence this module's own moduledoc says it exists to prevent ("Six were loud... Three were silent, and those are the ones this is designed against"). The fake now refuses the same way, gated on the same field. New tests infake_test.exsand an updated one infake_injection_test.exs(which previously called it with no credentials at all and got away with it) cover both branches.level2andtickershared one shard size, and the venue's ownlevel2ceiling is well under it — DpCryptoManagement's issue #22 continuing, not reopened. Thetickerstarvation fixed earlier in this file (the timed-out-subscribe retry entry above) is a separate, already-closed incident in the same file; this is a second, independent defect the sharding line above deliberately left unchanged pending a separate measurement ("Deliberately unchanged:@channelsorder... raised separately with the consumer instead"). That measurement is this entry.A consumer's real 406-symbol universe, on sixteen otherwise-healthy boots (0
send_timeout, 406/406quotescoverage):shards/1at the old shared@pairs_per_socket(100) split it[100, 100, 100, 100, 6], andlevel2's subscribe was refused on every 100-symbol shard —"too many L2 streams requested in a single session", 5,099 times — while the six-symbol shard's was not.coverage_by_kind/1answeredorder_book: 6throughout: exactly the tail shard's own count, pinning the cause on shard size rather than the connection, the alias fix, orticker(which has no such ceiling and was unaffected on the same boots).No Coinbase documentation states a per-session
level2product ceiling — re-checked 2026-09-06 against the Advanced Trade channels reference, connection overview and rate-limits page (which states an8-per-second-per-IP connect/message rate, not a subscription count), and the older Exchange product's separate rate-limits page (which states a different, inapplicable number: 10 duplicate subscriptions to the same product-channel pair, not the count of distinct products, and for a product this package does not speak). This package cannot narrow it by probing the venue either:level2is authenticated, and this repo's own testing strategy draws tier 3 (authenticated, live) as needing credentials this repo must never hold — the same line that already keeps this repo off order placement.level2now gets its own shard grouping, at its own, independent size —@level2_pairs_per_socket,6— rather than sharing@pairs_per_socket(100, stillticker's own size, unchanged) withticker.6is not a rediscovered venue limit; it is the largestlevel2subscription size this package has direct evidence the venue accepts, taken from the production numbers above (100 refused four times out of four, 6 accepted once out of one) rather than guessed at some unverified point between them. Every shard, either channel, now opens its own dedicated, single-channel socket — for the 406-symbol universe above, 5tickersockets (unchanged) plus 68level2sockets (ceil(406 / 6)), 73 total against 5 before, affordable now that removing in-packagelevel2book maintenance (the change above this one) cut per-frame decode cost roughly tenfold.Every new socket — either channel — is staggered on one
@shard_spacing_mssequence with every touchedtickershard ordered ahead of every touchedlevel2shard, soticker's own boot-time coverage stays exactly as fast as before this change whilelevel2's far more numerous shards ramp in behind it — for the 406-symbol universe, roughly six minutes for the lastlevel2shard, against a ceiling that previously never moved at all.@channel_spacing_ms— the wait betweenlevel2andtickersharing one socket — is deleted along with the shared-socket design it existed for; no socket carries two channels any more, so the busy-decoding hazard it guarded against cannot occur.@subscribe_retry_delay_mskeeps its value (still8_000ms) on its own reasoning rather than borrowing from a constant that no longer exists.An adaptive shard size, driven down at runtime by the venue's own refusal so a wrong constant could never be silently wrong forever, was considered and not built: correctly telling a live shard's bookkeeping apart from a stale one the venue already emptied on refusal is real complexity with its own correctness risk (silently under- or double-subscribing a shard), and did not clear its bar against a fixed, evidence-grounded constant plus the safety net that already existed and needed no change —
Socket'serror_kind/1already classifies "too many" as:rate_limitedand reports it as aCore.Noticeon every occurrence, andcoverage_by_kind/1already never marks a symbol covered for:order_bookon subscribed intent alone. Both are verified unchanged by this fix. If6is ever also refused, a consumer withsubscribe_notices/1wired up hears about it exactly as loudly as any other refusal in this file, and lowering the constant is a one-line change rather than a runtime decision made silently.New tests in
feed_test.exspin the structural fix: a credentialed feed given a symbol count that fits in onetickershard splits it into twolevel2shards, with thetickershard chosen as the call's synchronous primary; a credential-less feed never opens alevel2-keyed shard at all. The 12-shard resubscribe-floor test is now a 13-shard one, and its expected numbers drop the deleted@channel_spacing_msterm — both mechanical consequences of this change, not new behaviour of their own.@level2_pairs_per_socketwas6— a conservative lower bound, correctly labelled as one — and the real boundary is now measured:30. Same issue #22, continuing.6was never a rediscovered venue limit; it was the largest size this package had any positive evidence for at the time, with100(refused) as the nearest known failure and nothing in between actually tried. This package still cannot bisect a live, authenticatedlevel2session itself — that is tier 3, a line this repo does not cross for any endpoint. DpCryptoManagement can, and did, on 2026-09-06 (issue #22): a fresh socket per attempt, never reused, against their real 406-symbol scope —n = 6/12/25/30accepted,n = 31/35/50/100refused, confirmed by interleaving two runs back to back and by a contamination check (re-runningn=6andn=30immediately after a refusal run, both still accepted, ruling out probe-induced saturation as the cause).30is the largest value with positive evidence of acceptance,31the smallest with positive evidence of refusal — the boundary itself, not merely a known-good far below a known-bad. Seedocs/reference/coinbase/level2-session-limit.mdfor the full method and attribution.For DpCryptoManagement's 406-symbol universe this is 14
level2sockets instead of 68 (19 total instead of 73) and roughly 70 seconds to fullorder_bookcoverage instead of about six minutes — the same staggered,ticker-first connect sequence as before, just markedly shorter because far fewer sockets need it.A second question this raises got checked directly, not left open by omission. The consumer's harness used a fresh socket per attempt specifically to keep cumulative session state out of its own result, which means it cannot say whether Coinbase's ceiling counts concurrently-held products or every distinct product a session has ever carried. Checked against this package's own code:
reconcile_shard/7used to subscribe a shard's newly-added symbols onto whatever socket that shard already had open, and aMapSet's enumeration order being a function of its current keys (not insertion order) means ordinary universe churn — not only a caller literally adding a symbol — routinely hands an already-openlevel2shard products it has never carried before. At6this had enormous headroom; at30, aimed at a now-exact boundary, it had none: the very first churn past a full shard could have pushed one socket's lifetime subscription count to 31 even though its concurrent membership never left 30. Fixed by replacing the socket instead of mutating it whenever alevel2shard's membership would grow (reconcile_shard/7's"level2"clause,replace_level2_shard/7,terminate_socket/1) — a shard that only loses symbols keeps its existing socket, since removal cannot grow that count, andtickeris unaffected, since it has no known ceiling to protect. This makes "should never be over the ceiling" hold unconditionally, concurrently and cumulatively, per socket, regardless of which of Coinbase's two possible countings turns out to be real.One axis is still genuinely open and was not fixed here. The unconditional 60-second resubscribe re-issues a shard's unchanged symbols to its already-subscribed socket forever, which would feed an attempt-counted ceiling if Coinbase has one — this is the same open question
Feed's@default_resubscribe_interval_mscomment already named before this investigation, and this investigation did not close it. Two specific probes that would are recorded indocs/reference/coinbase/level2-session-limit.md, for whichever party next holds the credential to run them.DpCryptoManagement also reported a refusal that was not always a clean gate — some oversized subscribes delivered 1,300+ books alongside their
rate_limitednotice rather than refusing outright, once even atn=31, the smallest over-the-boundary value. This does not move the boundary (everyn ≥ 31refused, everyn ≤ 30did not) and has no explanation from either party; it is recorded, dated and attributed, as an unexplained venue characteristic indocs/reference/coinbase/level2-session-limit.md, not rationalised into a theory neither party has evidence for. This package should never itself trigger it — everylevel2subscribe it sends carries at most 30 symbols by construction, before and after this fix — andcoverage/1/coverage_by_kind/1need no change to stay honest if it ever does: both are built entirely from symbols that actually delivered a payload, and aCore.Noticenever touches that bookkeeping.New tests in
feed_test.exspin both the number and the fix:35symbols (rather than10) now produces twolevel2shards at30/socket; alevel2shard engineered to be missing symbols its own fresh chunking would include is replaced — old socket killed, new one recorded, target set intact — while one only losing symbols keeps its existing socket;tickergiven the identical setup keeps mutating in place; the deferred (non-primary-shard) replace path is driven directly, including the stale-message guard and a replacement socket that fails to open.The socket-replacement fix directly above is superseded: the question it hedged against is now measured, and the hedge was more expensive than the answer required. Same issue #22, continuing.
9139881replaced a growinglevel2shard's socket outright because this package could not tell whether Coinbase's per-session ceiling counted concurrently-held products or every distinct product a session had ever carried — a real open question at the time, and a full reconnect (fresh snapshot, coverage gap) was the cost of not guessing wrong either way.DpCryptoManagement answered it directly, 2026-09-07: three probes, each on ONE socket, using raw
Socket.subscribe/4— deliberately notFeed/update_symbols/2, so the result is evidence about the venue's own accounting, not this package's dedup. The same 30 products re-sent ~60s apart 14 times (~13 minutes): all accepted,books=0on every repeat — repeats do not accumulate, which also directly answers the "attempt-counting" open question from the previous entry (the unconditional 60-second resubscribe does not feed a cumulative counter, at any shard size). A different 30 products on the same socket without unsubscribing the first batch:cumulative=60— REFUSED, but the socket stayed alive and the first batch kept delivering (27/30 still ticking); only the second, unreleased batch was rejected. Four batches of 30, each unsubscribing the previous batch first: all four accepted, fresh snapshot each time — 120 distinct products through one socket, never more than 30 live at once. The ceiling is 30 CONCURRENT products per session, not 30 over a session's lifetime, andunsubscribereleases budget the venue actually honours.reconcile_shard/7's"level2"clause andreplace_level2_shard/7are gone.reconcile_shard_in_place/7now handles both channels, all three shapes a shard's membership can change (only losses, only gains, both), on the shard's EXISTING socket:removedis unsubscribed beforeaddedis subscribed, and the subscribe never goes out until the unsubscribe has returned:ok(retried on a transient send failure with the same bounded backoff a channel subscribe already gets —attempt_channel_reconcile/6,handle_unsubscribe_failure/8— withaddedwithheld, not sent anyway, if the retries exhaust). The order is load-bearing: probe 3 above works BECAUSE the departing batch's slots were freed before the arriving batch was requested; probe 2 is the identical operation in the other order and was refused, quietly — the socket stayed alive and already-flowing symbols were unaffected, with only the newly-requested ones silently missing. Shrinking before growing keeps a socket's transient concurrent count bounded bymax(length(current), length(wanted)), never more thanlevel2_pairs_per_socket, including mid-reconcile, not only at rest.What guarantee this package actually has is stated plainly, not assumed generous.
Socket.subscribe/4andSocket.unsubscribe/3both block onFrameSender's synchronousWebSockex.send_frame/2call, so ordering the two calls in code orders the two frames on the wire; TCP delivers one connection's bytes in the order they were written, and nothing in any probe has ever shown Coinbase reordering two frames on one connection. ButSocket.unsubscribe/3's:okmeans the frame was handed to the connection, never that the venue has finished releasing the departing slots — this protocol gives no acknowledgement frame for an unsubscribe to wait on. This package relies on frame order, not a confirmed venue-side state transition, and says so infeed.ex's own moduledoc ("unsubscribe before subscribe") rather than rounding the guarantee up. One gap stays open and disclosed: a permanently stranded unsubscribe (every retry exhausted) is not itself retried by the next resubscribe cycle, which only re-issues a plainsubscribe— closing that would need a cross-cycle retry ledger judged not to clear its own complexity bar against how narrow the gap is.docs/reference/coinbase/level2-session-limit.mdrecords all three probes, dated and attributed, and marks the cumulative-vs-concurrent and attempt-counting questions resolved. One thing is explicitly NOT resolved and is recorded as open, not decided either way: thelevel2_pairs_per_socketwarning and the reference doc both used to state that an oversized subscribe "closes the socket and loses that shard's entire coverage" — probe 2 above shows a cumulative overage refusal that did NOT close the socket. The original 2026-08-26 incident recorded socket closure for what may be a different case (a single oversized subscribe, not cumulative overage), and the consumer has offered to test the single-oversized case specifically; until that runs, both observations are stated and the conflict is left open, and the warning text states the worse of the two outcomes as the risk to plan for rather than asserting it as certain. (That test ran the same day — see the "Corrected claim" entry above this section for the result: refused wholesale, socket survives, same as probe 2.)New tests in
feed_test.exsreplace the socket-replacement suite: a growinglevel2shard reconciles on its existing socket (never replaced, never killed); a shard that both loses and gains sends the unsubscribe frame before the subscribe frame, on both the synchronous (primary-shard) and deferred (async) reconcile paths; a shard reconciling losses and gains together never reports more live products than its own shard size at any point a fake venue-tracking socket observes, including mid-reconcile; a transient unsubscribe failure is retried and the subscribe stays withheld until it succeeds; an unsubscribe that exhausts its retries withholds the subscribe entirely and reports a:coverage_changenotice naming both the departing and withheld-arriving counts.The fix directly above left its own gap: a permanently stranded unsubscribe was dropped from this package's own bookkeeping, not merely from the venue. Traced by the coordinator against the actual code, not taken on the summary above's word:
reconcile_shard_in_place/7recordedstate.shards[key].symbols = wantedunconditionally on both its sync and deferred clauses, regardless of whether the underlying unsubscribe ever succeeded. The WITHHELDaddedside already recovered — the 60-second cycle re-issuesstate.shards[key].symbols, which iswanted— but the STRANDEDremovedside did not: nothing re-issued the unsubscribe, and this module's own bookkeeping had already stopped tracking it as owed. Left alone this degrades into the exact failure this whole file exists to prevent — the venue's live count sitting atold ∪ wanted, eventually exceeding the shard's cap, refusing every later subscribe for that shard quietly (DpCryptoManagement's own probe 2 — socket alive, existing symbols still flowing, new ones silently absent) — and theaddedrecovery makes it WORSE, not better, by continuing to push subscribes into a budget the stranded slots guarantee cannot fit.Closed using the machinery already there, per the coordinator's own instruction, not a new subsystem:
state.pending_unsubscribes(%{{channel, index} => [symbol, ...]}) is written bystrand_unsubscribe/7whenever a reconcile gives up on releasing someremovedsymbols — the synchronous clause's single failed attempt (no retry chain of its own, so it strands immediately) and the deferred clause's exhausted retry chain alike — and read back byhandle_info(:resubscribe, _)on the identical unconditional cadenceretry_missing_shards/1already uses to recover a shard whose socket never opened at all; that function's own comment states the governing principle this reuses word for word. Each tick now sends{:channel_reconcile, key, socket, channel, pending, symbols, credentials}per shard — the shard's own pending entry asremoved, its WHOLE current membership (not a delta) asadded— through the SAMEattempt_channel_reconcile/6an ordinary reconcile already runs, so the retried release precedes that shard's resubscribe on the same tick for the identical reason it precedes one anywhere else in this file. For the ordinary case (nothing pending) this costs nothing beyond what a plainSocket.subscribe/4already cost, sinceunsubscribe_step/3short-circuits an empty list without sending anything.clear_pending_unsubscribe/3removes only the symbols a successful send actually covered (pending -- removed, not the whole key), so an unrelated, still-outstanding stranding on the same shard survives a different one clearing.strand_unsubscribe/7is symmetric — it merges, never overwrites.drop_unwanted_shards/3now also drops a shard's pending entry the moment the shard itself is dropped, sincehandle_info(:resubscribe, _)walksMap.keys(state.shards)specifically and would otherwise retry nothing for a key that no longer exists there — this is what keepsstate.pending_unsubscribesbounded by currently-open shards carrying an unresolved stranding, not by this module's all-time history of failures. A symbol churned out (stranding it) and back into the same shard before the next tick is not a hazard either:addedis always the shard's whole current membership, so the same reconcile's own subscribe puts it right back if still wanted, in order, costing at most one redundant frame pair.What was deliberately not built, and why, argued rather than assumed: correlating a stranding to a specific socket's own reconnect. A reconnect gets a fresh venue session, releasing every stranded slot on the old one whether this package notices or not — but
Socketholds no shard-correlating identity in its:link_up/:link_downnotices by design (its own moduledoc: "Socketholds no book to key by anything"), and WebSockex's reconnect keeps the same pid, so pid identity cannot substitute. Building that correlation would widenSocket's own contract for a case whose cost, left alone, is bounded: a stale entry retried against a fresh session sends one wastedunsubscribe(assumed idempotent-safe against an unsubscribed symbol, consistent with but not independently measured against this family's stated subscribe-idempotency assumption — labelled a gap, not hidden as a certainty) and is immediately followed, same reconcile, by a subscribe of the shard's whole current membership — self-correcting the same tick, never leaving the shard short a wanted symbol.transient_frame_failure?/1's real reach, stated rather than assumed generous:Socket.unsubscribe/3builds no JWT and checks no credentials, so it has no analogue of a subscribe's{:credentials_required, channel}— its only two possible failure shapes,:send_timeoutand{:send_exit, reason}, are both already classified transient. The non-transientcondbranch inhandle_unsubscribe_failure/8therefore does not fire against the real venue today; "permanently stranded" is reached, in practice, only by EXHAUSTING the bounded retry chain against a socket that keeps failing to send without ever actually dying (Process.alive?/1stayingtruethroughout — a genuinely dead socket short-circuits every reconcile path before reaching this branch, matching every other dead-socket guard in this module). The non-transient branch is kept anyway, wired to identical behaviour, for the same "do not assume a shape that happens to hold today" reasonhandle_subscribe_failure/6keeps its own — a two-branchcondreusing one existing helper, not new machinery for a case argued, not merely assumed, to be effectively unreachable.New tests in
feed_test.exs: the resubscribe tick retries a shard's pending unsubscribe before it resubscribes and a successful retry clears it; a retry that also fails leaves the entry in place for the next cycle; a synchronous (primary-shard) unsubscribe failure is stranded rather than dropped; dropping a shard entirely also drops its own pending entry.
Documentation
CLAUDE.md claimed this package parses Coinbase's
cb-after/cb-beforerate-limit headers; it deliberately does not, and never has. Those are pagination cursors, not rate-limit data, and Coinbase publishes nox-ratelimit-*orretry-aftereither — measured live 2026-08-28. A prior adapter's parser keyed off the cursor headers and returned three hardcoded constants labelled as measurements (remaining: 100,limit: 100, a reset time one minute out); porting it would have been exactly the fabrication this family refuses — recorded indocs/reference/coinbase/reconciliation.md§5.5, which CLAUDE.md's own claim contradicted. Corrected to state what actually happens: nothing is parsed, andCore.HttpClient's generic parser correctly answersnil.docs/reference/coinbase/endpoint-inventory.mdstill listed/best_bid_askand/product_bookas not implemented — family-wide defect sweep, Coinbase B3. Both were implemented and declared:experimentalincapabilities/0well before this release; the note was never updated when they shipped, which is part of why B1's missing public/private branch on/best_bid_askwent unnoticed. Both endpoints are now marked✓in the endpoint list, the stale "absent" note is corrected, and the newly measured fact from B1 is recorded where this file's other live measurements live:/best_bid_askhas no public form, verified live 2026-09-05, unlike/product_book, whosemarket/product_booktwin is real and public.usage-rules.md's "Streaming" section never said which symbol a delivered frame carries, and never mentionedresubscribe_interval_msat all — family-wide defect sweep, Coinbase B5. Both are consumer-facing behaviour a subscribing agent needs to act on correctly, and this file — the one that ships inside the Hex tarball and is not the README — was silent on both. The alias-attribution fix above changes what symbol arrives on every streamed frame;resubscribe_interval_mshas been a realFeedoption, forwarded straight through from{DpExchange.Coinbase, resubscribe_interval_ms: ms}, since the resubscribe-wedge fix above added it, and neither fact was checkable from the shipped docs. Added two sections: one stating a delivered frame is tagged with the symbol the caller subscribed to, never the venue's rewritten alias, including the degraded-attribution fallback and its:data_qualitynotice; one documentingresubscribe_interval_ms's 60,000 ms default, how to set it, and that a value below one full re-issue cycle for the current shard count is silently clamped to the computed floor and logged rather than honoured.README's endpoint counts were stale. It read "46 are declared
:experimentaland 41:unsupported" with "38" of those the venue's own absence. Run against the realcapabilities/0(mix run -e, 2026-09-05): 48:experimental, 39:unsupported, of which 37 arevenue_does_not_serve/0(the other 2 are@not_ported,get_funding/2andget_contract_stats/2). Corrected to the measured numbers rather than re-guessed.frame_sender.ex's moduledoc claimedWebSockex.send_frame/2has "no way to override" its 5-second timeout. The vendored websockex 0.5.1 exposessend_frame/3with a timeout argument, so the claim was wrong.FrameSender.send/3still calls the 2-arg form, so nothing about the actual timeout behaviour changes here — see the design doc's deferred section for why a longer timeout is a decision for later, not a drive-by alongside this correction.Every
tickerframe from the real venue failed to decode — 0Quotes delivered, ever, against live Coinbase, for the entire life of this package. Surfaced while chasing DpCryptoManagement's issue #22: a live test against 60 non-aliased, canonical-USDsymbols captured 500+ consecutivedata_qualitynotices and zeroQuotes in a 20-second window.build_quote/2readticker["time"]— a field that does not exist on the row. Confirmed against Coinbase's own CDP API reference for thetickerchannel, independently, twice: the timestamp lives on the message envelope ("timestamp", one per frame), never on the individualtickersrow. Every hand-built test fixture in this package — including the ones ported from the host adapter's own test suite (baseline_test.exs, "Phase 5.7") — encoded the identical wrong assumption, which is why this passed every test ever written against it and only ever failed against a genuine live socket.dispatch/2now reads the envelope's owntimestampand threads it down tobuild_quote/3; the per-row field is gone.Applied the same fix to
l2_data/OrderBook, which had a related but different defect:deliver_book/2didn't read any venue timestamp — it substitutedDateTime.utc_now/0unconditionally, which is the exact substitution this file's own moduledoc already named as wrong for the ticker path (Core.Types.Quote's "never substitute now" principle) while doing it anyway one function down.deliver_book/3now reads the same envelopetimestampand fails closed if it's absent, same asbuild_quote/3— the maintained book state still updates either way, only the outgoing delivery is withheld.Does not, on its own, explain why
level2/OrderBookdelivered zero data in any of the three live tests run while chasing #22 — the oldDateTime.utc_now/0fallback always succeeded, so this was never why level2 was silent there. That remains open.A
level2capacity refusal from the venue was reported as:credentials_rejected— DpCryptoManagement's issue #22, filed as a suspected regression of #20. Coinbase answers both a genuine auth failure and "too many L2 streams requested in a single session" through the identical{"type":"error","message":...}frame shape.Socket.dispatch/2collapsed both into:credentials_rejected— the shape the original stub-token incident produced — which sent a consumer that finally wiredsubscribe_notices/1looking for a broken credential that was never broken. Now classified by message content: a capacity refusal reports:rate_limited, Core's own kind for pressure rather than identity: everything else keeps the original:credentials_rejectedbehavior.This does not, on its own, explain or fix why 4 of 5 shards deliver nothing. #20's fix addressed a genuine, confirmed bug (an unstaggered connect burst) but issue #22's live evidence — the refusal persisting unchanged across 15+ minutes and two clean restarts, with every socket healthy and connected — describes a permanent per-shard rejection, not the transient reset #20 targeted. Whether Coinbase enforces
level2session capacity per account rather than per connection, which would make multi-socket sharding for this channel fundamentally incompatible with this venue regardless of spacing, is not something this repository can verify without live credentials. Left open pending that evidence.A scope wide enough to need three or more shards opened them all in the same instant instead of staggered, and 60-second resubscribes re-issued the same burst every minute — DpCryptoManagement's issue #20, a real ~406-symbol/5-shard production scope where 4 of 5 shards (400 symbols) never delivered a single tick while the fifth did.
reshard/1scheduled every shard past the synchronous first one with the same fixed@shard_spacing_msdelay rather than one increasing per shard, so all of them opened together — exactly the connect burst this module's own moduledoc already named as the failure the venue answers with resets. Only a suite exercising three or more shards could have caught it; the existing test only ever covered two (one synchronous, one staggered), where a single fixed delay is indistinguishable from a correct one. Fixed by scheduling each shard's turnposition * @shard_spacing_msafter the one before it, applied to both the initial open and the unconditional 60-second resubscribe. A regression test now exercises three shards.Feed.fan_out/2crashed on a subscriber registered by name — DpCryptoManagement's issue #15.subscribe/2'sto:option accepts any value, andfan_out/2calledProcess.alive?/1on it directly — which only accepts a pid and raises on anything else. A consumer registering itself under a name (ordinary OTP practice) and handing that name toto:crash-looped the wholeFeedGenServer on every delivery. Fixed by resolving a subscriber (pid or name) to a pid first, treating an unregistered name the same as a dead pid: silently skipped, never a crash.feed_test.exs's own fake sockets never answeredWebSockex.send_frame/2's internal:gen.call, silently turning several tests into a real, load-dependent race against two independent ~5-second timeouts (WebSockex's own hardcoded one and:sys.get_state/1,2's default) rather than a fast, deterministic assertion — the file's slowest tests ran 5–15 real seconds each and occasionally lost the race outright under load from the rest of the suite. Not flakiness to route around: traced to a root cause and fixed there. One fake now replies immediately per:gen's own reply protocol (removing the stall entirely); the other, which intentionally models a socket whose frames fail, now fails immediately rather than by never replying. Fullfeed_test.exsrun time: ~45s → ~3s.to_order/1read bothOrder.quantityandOrder.filled_quantityfrom the same venue field — DpCryptoManagement's issue #12.order["filled_size"]populated both, so a fetched order'sremaining_quantity(quantity minus filled) was always zero, even for a genuinely open, partially-filled order — a correctness bug for anything reconciling open-order state.quantitynow reads the venue's own record of what was requested, fromorder_configuration's leafbase_size— the same fieldclosing_configuration/1already reads for a closing order's size, on the same response envelope. A quote-sized market order's leaf carriesquote_sizeinstead, with no rate here to convert it, soquantityisnilrather than a guess in that case.
Added
level2is subscribed and decoded —streamablegains:order_book. The channel was recognised and had working auth machinery since an earlier release but was never actually requested;capabilities/0said[:quotes]while the code that would have served:order_booksat unused.Socketnow maintains a real per-symbol book — snapshot then patched byupdatedeltas,new_quantity: "0"removing a level — and deliversCore.Types.OrderBooksorted best-price-first on every change, matching this family's existing convention (see Schwab's book services) of emitting on every venue frame rather than throttling client-side.Sharded — this venue's whole subscription no longer runs on one socket. Measured 2026-08-27 against a live ~400-symbol universe: a
level2subscribe over the venue's real per-session limit gets"too many L2 streams requested in a single session"and the socket closes, a total data gap rather than degraded coverage — 355 of 405 pairs went stale, 1,480 refusals in one log window.Feednow opens one socket per 100 symbols (the number from that incident, carried over rather than re-derived), spaced to avoid a connect burst,level2subscribed beforetickeron each and the two spaced apart so a snapshot decode in progress does not turn atickersubscribe into asend_timeout.A reconnect now resubscribes. WebSockex reconnects a dropped socket on its own and leaves it subscribed to nothing — silently, since a connected socket receiving nothing looks the same as a quiet market.
Feedre-issues every shard's current subscriptions on a 60-second timer, unconditionally; the reference implementation this replaces lost a venue's entire coverage to exactly this gap for roughly forty minutes before anyone noticed the chart had gone flat.level2is skipped for a credential-less subscriber rather than failing loudly for no reason. It requires a credential andtickerdoes not; a caller with no credentials only ever wanted the public channel, and sending a doomed authenticated subscribe would either surfacecredentials_requiredas this call's synchronous result — masking thattickerworks fine — or cost a wire round trip to learn what the credential's absence already answers.
Documentation
- The
:unsupportedlist is now split.venue_does_not_serve/0names the 38 endpoints that are Coinbase's own absence — staking reads, the one-step convert, funding rails, option chains, watchlists — each with the source and date behind it; three (get_funding/2,get_contract_stats/2,list_instruments/1) stay under@not_portedbecause they are the venue's surface and this package's backlog, not the venue's gap. Robinhood found four callbacks mislabelled the other way; this pass checks Coinbase's own list rather than assume it was filed correctly the first time. README.mdstates what the contract covers — 46 of 87 callbacks:experimental, and points atnegative-claims.mdfor every absence's source.docs/reference/coinbase/endpoint-inventory.md's counts refreshed. It read "everything authenticated is absent" until this release, which had been true at capture and stopped being true as this package grew — the vendor-side numbers had not moved, this package's coverage of them had, and the section conflated the two.
Documentation
Every negative this package makes is audited —
docs/reference/coinbase/negative-claims.md, twelve claims with the source and date consulted for each. Nine hold; three were wrong, and all three for the same reason: each was a true statement about one endpoint restated as a claim about the venue.supports_order_preview: falseandsupports_order_replace: falsewere assumed without reading the list the endpoints are on — the second mattered more, because it told a caller to cancel and re-place, opening a window in which no order is live. Andget_trade_volume/2's "Advanced Trade does not aggregate" was read off/products/volume-summary, which is market volume and a different question.The check that would have caught all three is the one the table now enforces: name the endpoint you looked at, and the date.
usage-rules.mdgains the surface this release added — the two accounts a futures position is margined from, Prime's separate host and credential triple, convert's absent expiry, portfolios as addresses, and the fee/volume pair.AGENTS.mdgains a pointer to this package's ownusage-rules.md, so a reader who opens the generated file knows where the package's rules actually are.
Changed
- Core dependency moves to
~> 0.1.36, andplace_orders/3is declared absent with the reason: this venue places one order per request. A batch is one request the venue accepts or rejects as a unit, and a caller placing several here callsplace_order/3several times and reconciles the outcomes itself.
Added
Key permissions and the server clock —
get_roles/1,get_server_time/1and atest_connection/2that is no longer declared absent.can_transferis a separate permission fromcan_trade, and a key routinely holds one and not the other. Asking is cheaper than discovering a missing one from a refused withdrawal. The response also names the portfolio the key is scoped to, which is where a caller finds out whose balance it has been reading.test_connection/2asks two different questions and picks by what it was given. Without credentials it reads the public clock — reachability alone. With them it reads the key's permissions, which fails if the key is wrong and answers what the key can do if it is right. An unreachable venue and an unaccepted key are different problems.get_server_time/1returns the venue's own map undiffed. The difference a caller cares about is against its own clock at the moment it asked, and computing it inside the package would hide the round trip in the number. It is worth reading at all because this venue's JWT window is two minutes: a host clock further out than that produces authentication failures that look like a credential problem.Convert, portfolios and the transaction summary — the last ten Advanced Trade endpoints in the coverage plan's Phase 11.
Convert is the facade's only two-step operation, and Advanced Trade states no expiry at all.
expires_atisnil, which means "not stated" and never "open-ended": a caller committing a lapsed quote can be filled at the current rate rather than refused, which is the dangerous outcome because the operation looks like it succeeded and every number is real.commit_conversion/2and evenget_conversion/2re-ask for both accounts — the venue's own rule, unusual for a read — and this package fills neither in: a conversion committed against accounts the caller did not name happens between the wrong two balances. A status this package does not know maps tonil, never the nearest one.A portfolio is an address, not a value.
list_portfolios/1returns them,get_portfolio_breakdown/3returns what is inside one — a different and much larger answer — andcreate_account/1andrename_account/3reach the portfolio endpoints, because Advanced Trade has no notion of creating an account. Deleted portfolios stay in the listing: the venue keeps them because old orders still name their ids, and filtering them out would make a historical id look like one that never existed.get_trade_volume/2was declared absent on a claim that was wrong. This package held that "Advanced Trade does not aggregate" the account's own volume; the transaction summary does, involume_breakdownper volume type withadvanced_trade_only_volumeandcoinbase_pro_volumebeside it. The claim had been made from the market volume endpoint's absence, which answers a different question. The two account totals ride alongside the breakdown rather than being folded in: the venue documents the first as non-inclusive of the second, so adding either to the breakdown double counts.get_fees/2carries bothfee_tierandfee_tier_without_promotion— they differ while a promotion is running, and it can end between two calls — and keeps the tax'sINCLUSIVE/EXCLUSIVEflag, because the same rate quoted either way is a different amount of money.US derivatives — the nine CFM endpoints.
get_positions/1andlist_futures_positions/1,get_futures_position/3,get_futures_balance_summary/2, the three sweep calls, and the three intraday-margin calls.Two accounts, and the balance summary names both. Futures margin from an account held with Coinbase Financial Markets; spot sits in one held with Coinbase Inc.
cfm_usd_balanceis the first,cbi_usd_balancethe second,total_usd_balancethe pair — and a caller sizing a futures position against the total is sizing against money that is not there. Every amount keeps itscurrency; flattening it off is how two currencies get added.:realised_pnlisnilon aTypes.Positionfrom this venue, and that is not an omission. Coinbase publishesdaily_realized_pnl— what the position realised today — and no lifetime figure. Putting a daily number in a field that means the position's answers a different question under the same name: a caller summing it across reads counts one day repeatedly. The daily figure is not discarded —list_futures_positions/1returns the venue's own row, where it keeps its own name, along withexpiration_time, whichTypes.Positionhas no place for either because a future expires and a perpetual does not.A sweep is scheduled, not settled.
schedule_futures_sweep/2queues a move out of the futures account andlist_futures_sweeps/2reports the queue; a listed sweep has not happened. Omitting the amount sweeps every available excess dollar — the venue's documented default, stated here because a caller reading a missing amount as "nothing" would move the lot.cancel_futures_sweep/2cancels the pending sweep and takes no id.INTRADAY_MARGIN_SETTING_UNSPECIFIEDis not_STANDARD. It is the venue declining to say, and mapping it to the safer-sounding value would assert a setting the account may not have. The venue's own strings are returned and required on the way in, with no default:UNSPECIFIEDis a value in the enum, and choosing it for a caller would set the account to something it did not ask for.get_current_margin_window/2carries both kill-switch flags. An account that believes it is on intraday margin while the switch is enabled has more leverage in its plan than in its account.supported_instrument_typesgains:future.:perpstays absent: Advanced Trade's perpetuals are the INTX endpoints, which areAPPROVED-SKIPas deprecated, and declaring a surface this package does not reach would be a claim about the venue standing in for one about the package.Coinbase Prime custodial staking —
DpExchange.Coinbase.Prime, all nine endpoints, withstake/3andunstake/3now live on the facade.A different product, host and signing scheme. Everything else in this package talks to
api.coinbase.com/api/v3/brokerageand signs a CDP JWT; Prime talks toapi.prime.coinbase.com/v1and signs an HMAC under an access key, a passphrase and a signing key that Advanced Trade neither issues nor accepts. Two of the three credentials is{:error, :missing_prime_credentials}rather than a request that is signed and wrong.These are not the CDP Staking API. Those seven are on-chain: they take a wallet address and return unsigned transactions for the caller to sign and broadcast. Reaching them through
stake/3would be this family's recurring failure at its most expensive — a caller believing it had staked while holding a transaction nobody sent.Two scopes, and this package picks neither for you. Prime publishes every staking operation across a portfolio and again on one wallet, and the two are not interchangeable: a portfolio-scoped unstake redeems across every wallet in the portfolio.
stake/3andunstake/3follow only what the caller said — a:wallet_idmeans the wallet, its absence means the portfolio — andopts[:portfolio_id]is required, refused as{:error, :missing_portfolio}before a request is made.Four callbacks stay declared absent with the reason: Prime publishes no rate schedule and no staking history at either scope;
staking/statusnames one wallet and is not "every staked position, one per asset" (reachable asPrime.staking_status/4); andclaim_rewardsis a write that moves accrued rewards, not a report of what accrued.Nothing here has been run against Prime. The paths are read from the vendor's pages on 2026-08-31 — thirteen pages, nine endpoints, four pairs documenting one path under two names — and the signing scheme from Prime's authentication documentation. This repository holds no Prime credential and money-moving endpoints are answered in production, not by a test here. Responses come back as the venue's own maps for the same reason: a
Types.StakingBalancebuilt from an unverified field name is a plausible number in the wrong field.Payment methods and the internal move:
list_payment_methods/2,get_payment_method/3(GET /payment_methods,GET /payment_methods/{id}) andtransfer_internal/4(POST /portfolios/move_funds).A payment method's flags disagree with each other. Each row carries
verified,allow_depositandallow_withdraw, and a method verified for deposit is routinely not verified for withdrawal. Rows stay the venue's own maps and no "usable" boolean is synthesised from them — collapsing the flags is what makes a caller move fiat through a method the venue refuses.get_payment_method/3is the read; the listing is a snapshot. A method's state changes without the account doing anything, and selecting the row out of an earlier listing answers with whatever was true when that listing was taken.transfer_internal/4moves nothing off Coinbase — no chain, no address, no network fee. Both portfolio uuids are required and neither is defaulted: a move missing either is{:error, :missing_portfolio}before a request is made, because the alternative is shifting funds between portfolios the caller never named. The amount is sent in full notation, sinceDecimal.to_string/1's scientific form is not a number this venue reads.
Changed
Core dependency moves to
~> 0.1.33, and with it twelve callbacks are now declared rather than missing. Nine are declared absent with the reason, checked against the venue's own reference on 2026-09-01: Advanced Trade publishes no allowlist (request_approved_address/4,remove_approved_address/3), no networks list (list_networks/2), no fiat registration (add_payment_method/2), no fee promotions (list_fee_promos/1), no FX publication (get_fx_rate/3), no notional valuation (get_notional_balances/3) and no custody product (list_custody_fees/2).get_transactions/2is absent for a different reason worth stating./transaction_summaryexists and is not it: that endpoint reports what the account traded in a window and what it cost, not an enumeration of deposits, fees and adjustments. Returning it here would have answered a different question while looking like this one.quantization/1— what the venue will actually accept, andRest.get_product/2for the whole record. Both were:unsupported.The venue names four increments and they are not interchangeable.
quote_incrementbounds the price andbase_incrementthe quantity; a caller rounding a price to the base increment produces an order the venue rejects on a field it did not name. Both minima are carried too —base_min_sizeis units andquote_min_sizeis cash, and a market order sized in cash is bounded by the second where a limit order in units is bounded by the first.statusis the venue's own word, unmapped: a boolean would lose the difference between a product that is paused and one that is gone.get_symbols/1reads the authenticated catalogue when a credential is present. Third and last of the public/private path corrections — the book, the candles and now the product list were all reading/market/…regardless.get_trades/2— the public tape.get_price/2already reads this payload and keeps only the newest print, because aQuotehas room for one price; the rest were discarded at the boundary. This returns them.Not
get_trade_history/2, which is the credential's own fills.brokenisfalseon every print — the ticker publishes no bust flag, and a venue with nothing busted reports nothing busted.get_historical_prices/4reads the authenticated candles path when a credential is present. The venue publishes the same candles twice —/market/products/…public and/products/…for a credential — and this always called the public one, so a caller holding a credential was silently forgoing whatever the authenticated view adds. Same correction as the product book.get_order_book/2— depth, which this package declared:unsupported.GET /product_bookfor a credential and/market/product_bookwithout one — the venue publishes the same book twice, and reading the public one while holding a credential would silently forgo whatever the authenticated view adds. The venue'slimitandaggregation_price_incrementare passed through.Both sides come back as the venue ordered them. Re-sorting would hide a venue that sent a crossed or out-of-order book, which is exactly the thing worth seeing.
A book the venue did not date is refused. A depth snapshot carrying the client's clock cannot be told apart from a current one, and a stale book read as current is the most expensive wrong number here.
sequencestaysnil— the endpoint publishes none, and a caller must not learn to detect stream gaps from a REST book.
Fixed
get_top_of_book/2now carries the sizes. It read/products/{id}/ticker, which publishesbest_bidandbest_askand nothing about how much is there — sobid_sizeandask_sizewerenilon every response.That
nilwas honest and it was avoidable: the venue publishes/best_bid_ask, whose pricebook carries the size at each level. A price without a size is half a top of book — a caller sizing against the best bid needs to know whether there is 0.01 there or 40, andnilgave it no way to ask.An empty side is still
nilrather than zero: one side of a book can genuinely be empty, and zero would claim someone is quoting nothing at a price of nothing.get_trade_history/2— past fills.trade_typeis not decoration. Regular fills carryFILL; the venue also emitsREVERSAL,CORRECTIONandSYNTHETICfor adjusted ones, and a reversal is not a trade that happened.Core.Types.Fillhas no field to say which is which, so summing a mixed list produces a position and a cost basis that are both wrong and both plausible. This returns onlyFILLrows by default, andopts[:trade_types]widens it — returning all four under a type that cannot distinguish them would be a substitution, and refusing them entirely would hide corrections the venue made.A fill the venue did not date is refused, not stamped with the local clock: a fill is an event at a moment, and a client timestamp places it wrongly in a history while looking entirely reasonable.
fee_currencyisnilrather than the pair's quote guessed from the symbol — a fee can be charged in a third asset and often is.UNKNOWN_LIQUIDITY_INDICATORmaps tonil, because neither:makernor:takeris an honest answer to the venue saying it does not know.Filters go to the venue rather than being applied to the page it returned, and the walk follows
cursorto a page bound.get_balances/2andget_accounts/2. The package could not say what the credential holds.The venue reports
available_balanceandholdand no total. The total here is their sum — arithmetic on two numbers the venue stated, not an estimate — and it isnilwhen either is missing rather than the other one alone. "Available 1.25, total unknown" and "total equals available" are different claims, and a consumer sizing against the second when the first is true trades against money that is held.The endpoint pages, at 49 by default and 250 at most, and this follows the cursor. A caller reading one page holds some of its balances with nothing to say which are missing, and every number on that page is real — which is what makes stopping there worse than failing.
@max_account_pagesbounds it, so a server that always sayshas_nexterrors rather than looping inside a facade call.get_accounts/2is separate because an account is more than a number: a caller routing an order needs the uuid and the platform, and a caller sizing one needs the balance. Collapsing them would lose the first.opts[:uuid]reads the single-account endpoint.:timestampis when the request was made — a balance has no venue event time.convert/4andget_trade_volume/2(Core 0.1.22) are declared unsupported, with the reasons checked. Advanced Trade's convert is the two-step form —POST /convert/quote,POST /convert/trade/{id},GET /convert/trade/{id}— which isquote_conversion/4and friends, scheduled separately. The one-stepPOST /conversionsbelongs to the Exchange API, a different product this package does not reach./products/volume-summaryis market volume and lives there too;get_trade_volume/2asks what this account traded, which Advanced Trade does not aggregate.preview_replace/4andclose_position/3. Two documented endpoints this package had no facade for.POST /orders/edit_previewprices an amendment before it is made. It is notpreview_order/3with an order id: the venue prices the amendment against the resting order's own state, including whatever of it has already filled, and its response carriesaverage_filled_priceandorder_margin_total— numbers a fresh order does not have. It takes the same:price/:quantitychange setreplace_order/4does and refuses anything else before the request.POST /orders/close_positionflattens a position by having the venue place the closing order. The returnedOrdercarries no side. The venue never states one, and it worked the side out from a position this package did not read — filling in:sellbecause closing is usually selling is wrong exactly where it matters, on a short. The order type, time in force and size are read, from theorder_configurationthe venue echoes back, and a configuration key this package does not recognise leaves themnilrather than picking the nearest.
Fixed
cancel_all_orders/2is declared unsupported, with the reason checked.POST /orders/batch_canceltakes an explicitorder_idslist — it is the endpointcancel_order/3already uses, one id at a time. There is no "cancel everything" call here, and assembling one fromget_orders/2plus a batch would be N partial outcomes with no way to reach an order that appeared between the listing and the cancel.BREAKING:
get_historical_prices/4returnsCore.Types.Candle. It was returningQuotes withprice: close.The venue sends open, high, low and close for every bar. Three of them were discarded here, at the boundary, where no caller could see it happen — and everything that came out was a real number, so nothing looked wrong. A caller reading
pricewas holding one corner of a bar with no way to learn it.This is the same defect the coverage plan's 2.10 found in Schwab, with the same reasoning behind it, still live here after that one was fixed. The fake had it too: it returned
get_price/2'sQuote, so the suite agreed with the bug it existed to catch.Bars now carry all four prices and
:opened_at— the venue's own bucket start, used as-is. A bar the venue did not date is refused with:missing_venue_timestamprather than stamped with the local clock, which would place it wrongly while looking right.
Fixed
This package claimed the venue has no order preview and no atomic replace. It has both.
supports_order_previewandsupports_order_replacewere declaredfalseon those claims, and neither was checked against the venue's reference. Coinbase publishesPOST /orders/previewandPOST /orders/edit; both flags are nowtrueand both endpoints are implemented.The replace claim was the worse of the two. Its moduledoc called
supports_order_replace: false"a claim about risk rather than convenience", because cancel-then-replace opens a window in which no order is live. The risk was real and the claim was wrong: the package was describing a hazard it was creating by not implementing the endpoint that avoids it.
Added
preview_order/3builds the sameorder_configurationasplace_order/3, so a preview is a preview of the order that would actually be sent. A200carrying a populatederrsis a refusal — returning it as a successful preview would tell a caller its order is fine when the venue has already said otherwise. Awarningis passed through and does not make it a refusal.replace_order/4edits price or size in place. Any other change is refused rather than dropped: a caller trying to change the side is describing a different order, and editing only the price would leave it holding one it did not ask for. The venue's edit response carries no order body, so the order is read back rather than reconstructed from the request — reporting what was asked for as though the venue had confirmed it is the mistake this whole contract is written against.
Added
cancel_order/3,get_order/3,get_orders/2. The order lifecycle, where there was none.Cancellation is a batch endpoint that refuses per order.
POST /orders/batch_cancelanswers with aresultsarray carrying its ownsuccessandfailure_reasonper id, so a200says nothing about whether anything was cancelled. A batch of one is still a batch. An order already filled comes back as a refusal, not an:ok— "I cancelled it" and "it was not there to cancel" are different facts, and a caller retrying on the second is chasing nothing.CANCEL_QUEUEDmaps to:open, not:cancelled. An order accepted for cancellation is still live until the venue says otherwise; reporting it gone invites a second order for the same exposure.A status, side, order type or time-in-force this package does not recognise is
nil, never the nearest atom. A venue adding a word later produces an absent field rather than a plausible wrong one.get_orders/2filters at the venue rather than in this package — a client-side filter over one page would silently drop matching orders sitting on the next. It returns one page and does not follow the cursor, which is stated rather than left for a caller to discover while reconciling.place_order/3. This venue could not place an order; it can now.Coinbase names the order type and the time-in-force in a single key —
limit_limit_gtc,market_market_ioc,stop_limit_stop_limit_gtd— and the set of names is sparse. There is nolimit_limit_ioc, nomarket_market_gtc.A pair the venue does not name is refused before the request is sent. Sending
{:limit, :ioc}aslimit_limit_fokwould place an order that fills-or-kills where the caller asked for immediate-or-cancel, and every field in the request would look right.Three further refusals rather than defaults: a limit without a price, a stop-limit without a stop price, and a market order sized in neither base nor quote.
post_onlyis omitted when unset rather than sent asfalse, because silence is not a decision to take liquidity.A
200carryingsuccess: falseis a refusal, not a placed order.client_order_idis the venue's idempotency key: a caller's own is passed through, and a v4 UUID is generated from the VM's CSPRNG when absent.
Added
DeprecatedEndpointsTest— fails the build if any code path constructs one of Coinbase's six vendor-deprecated INTX endpoints. They are absent today; nothing kept them absent.docs/reference/coinbase/endpoints-enumerated.tsvand a rewritten inventory: the documented surface is 712 REST operations and 46 socket channels, enumerated endpoint by endpoint from all 806 reference pages, replacing a page count. Deribit alone was recorded as 37 and is 115 — Coinbase renders it as twelve sibling trees with noderibitin their paths.- Prime's custodial staking enumerated: 13 documentation pages, 9 endpoints, four pairs being duplicate pages for one path.
Added
- Repo scaffold from the DpExchange standard; extraction pinned to the host's
553fa787with its working-tree state recorded, since the Coinbase subtree was dirty at extraction time.