# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 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]

### Fixed

- **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 same `reshard/1` call —
  `drop_unwanted_shards/3` dropped `state.pending_unsubscribes[key]` unconditionally for
  every shard no longer wanted, including one `strand_unsubscribe/7` had 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 consulted
  `state.pending_unsubscribes` at all — only the 60-second `:resubscribe` tick 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's `put_in` ran last won `state.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/4` now only drops a vanishing
  shard whose release is confirmed clear; `reconcile_shard_in_place/8` folds
  `pending_unsubscribes` into 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 `Feed` or `Socket` printed the CDP `api_key`/`api_secret` pair in
  cleartext, in OTP's own crash report.** Both processes hold `:credentials` for their
  entire lifetime — `Feed` to keep resharding and resubscribing, `Socket` to sign every
  authenticated `subscribe/4` — and both stored it as a bare map field in `GenServer`/
  `WebSockex` state. 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 in
  `DpExchange.Coinbase.Credentials`, a struct whose `Inspect` is derived with `except:`
  naming both fields, at the point credentials enter state — nowhere else in either
  module changes, because a struct is a map and `Auth.jwt/2`'s
  `%{api_key: k, api_secret: s} = credentials` still 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 reads `credentials: #DpExchange.Coinbase.Credentials<...>`. See
  `Credentials`'s moduledoc for the full mechanism, including why it also closes a second
  leak (a `FunctionClauseError`'s printed argument list goes through the same `Inspect`
  protocol as a crash report's state).

- **A shard's socket crashing took the whole `Feed` down with it, silently discarding
  every subscription this feed had ever been given.** `Socket.start_link/1` runs inside
  `Feed`'s own `handle_call`/`handle_info` (`get_socket/1`), which links every shard's
  socket to `Feed` the way `start_link` always does. `Feed` never called
  `Process.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
  untrappable `EXIT` signal along that link and crashed `Feed` too. `DpExchange.
  Coinbase.Supervisor` then restarted `Feed` from the *static* `opts` it was given at
  tree-start, which never carry a consumer's later `subscribe/2` calls: 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 running `Feed`
  the way `get_socket/1` does and killing it with `Process.exit(pid, :kill)` (not
  `:normal`, which a non-trapping process ignores), which crashed `Feed` before this fix
  and does not after.

  `Feed` now traps exits and isolates a crashed socket to the one shard it belonged to:
  only that shard's symbols lose coverage, only the `data_kind()` that shard's channel
  carries is cleared from `coverage/1`/`coverage_by_kind/1` (a `level2` crash no longer
  erases a symbol's still-healthy `ticker` quote), a `:link_down` `Core.Notice` reports
  the crash, and the shard reopens immediately rather than waiting out the next
  `:resubscribe` tick (up to 60s by default). Every other shard, on other sockets, is
  untouched.

- **BREAKING: `supported_order_types` and `supported_time_in_force` were both `[]` while
  `{:place_order, 3}` is `:experimental` and `Rest.order_configuration/1`'s own
  `@configurations` cross-product builds three order types across four time-in-force
  values.** `Capabilities.new/1` validates the *contents* of these lists but never that a
  venue with an active `place_order/3` declared anything, so two empty lists passed every
  check. Now `supported_order_types: [:market, :limit, :stop_limit]` and
  `supported_time_in_force: [:ioc, :fok, :gtc, :gtd]` — read directly off
  `@configurations`, not invented. `:stop` and `:post_only` are deliberately absent:
  the venue's cross-product has no `:stop` entry (only `:stop_limit`) and Advanced Trade's
  order endpoint has no post-only flag. Found by a cross-package audit;
  `dp_exchange_robinhood` defaulted the same two fields the same way for the same reason.

- **`child_spec/1` did not declare `type: :supervisor`, so OTP defaulted it to `:worker`**
  — which also defaults `:shutdown` to `5_000`ms 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 diffing `child_spec/1` across all five venue packages against each other;
  `dp_exchange_schwab` was the only one that already declared it.

- **`get_top_of_book/2` answered a missing local credential with `{:refused, :missing_credentials}`** — in the real `Rest` client and in `Fake` alike — 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 `:refused` for 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's `Fake` (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/3` included.** `Auth.rest_headers/4` was written
  as `Core.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/5` and `Rest.json_request/5`
  both handed the result straight to `HttpClient.request/5` without checking whether an
  `Authorization` header came back.

  For a public market-data `GET` that was harmless — Coinbase serves those anonymously.
  For `json_request/5` there is no public path: every caller of it is a write, so a
  malformed `api_secret` turned a live order into an unauthenticated POST that the venue
  answered with an opaque `401`, 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/4` now returns `{:ok, headers} | {:error, reason}` and both request
  paths gate on it with `with`, the way every other venue package in this family already
  gated on its own `Auth.headers`. The `nil`-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_secret` of
  `"-----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/2` raised `KeyError` on credentials it could not read**, rather than
  refusing by name. It read `credentials.api_key` unconditionally, so `nil`, `%{}` or a
  map assembled with a typo'd key crashed inside signing — reachable from every write
  endpoint through `Rest.json_request/5`, which carries no `nil` guard of its own, and
  from `Feed`'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 its `raw_status: true` option 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`, `401` or `403` contains 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.

  `Rest` now passes `raw_status: true` and matches the status exactly, as the other four
  venue packages already did. `404` remains `{:refused, :not_listed}`; `400`/`401`/`403`
  are 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 —
  and `test_connection/2`'s moduledoc — both said refusal. Both were right; the assertion
  was pinning the bug.

### Changed

- **`capabilities/0` now declares `authenticated_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_channels` names `level2`, and `Feed` subscribes
  `["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_socket` is now a supervision option**, not only the internal
  `@default_level2_pairs_per_socket` constant it defaults to:

  ```elixir
  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/30` accepted, `n = 31/35/50/100` refused; adopted as
  the constant in `9139881`) — the measurement is not being re-opened, only made
  adjustable. A value below `1`, or a non-integer, fails `Feed.start_link/1` at start with
  an `ArgumentError` rather than being coerced. A value above `30` is honoured, not
  capped — with a `Logger.warning` naming the measured ceiling, its date, and the concrete
  risk: an oversized `level2` subscribe 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: `30` is 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. See `feed.ex`'s own moduledoc, `"level2_pairs_per_socket — a
  supervision option"`, and `usage-rules.md` for the full reasoning
  (`DistortionPoint/dp-exchange-core` issue #22).

- **`shard_spacing_ms` is now a supervision option**, not only the internal
  `@default_shard_spacing_ms` constant it defaults to — the delay between opening each
  successive shard's socket, whichever channel it carries, and the same delay
  `reconcile_shard/7` now applies when reconciling several already-open shards in one
  `update_symbols/2` call:

  ```elixir
  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, fails `Feed.start_link/1` at start with an `ArgumentError`, the same shape
  as `level2_pairs_per_socket`; `0` is NOT refused — `Process.send_after/3` accepts 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 `125`ms
  (`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 a `Logger.warning` naming 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. See `feed.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.md` records 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.exs` existed 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: `Socket` no longer maintains a `level2` order book. An `update` frame now
  delivers `dp_exchange_core`'s new `Types.OrderBookDelta`, not a full `Types.OrderBook`.**
  A consumer that used to receive a rebuilt `Types.OrderBook` on every `l2_data` frame —
  including a single-row `update` — now receives a `Types.OrderBook` only on `snapshot`
  (once per subscribe/resubscribe) and a `Types.OrderBookDelta` on every `update`: the
  venue's own changed rows, in the venue's own order, both sides interleaved exactly as
  the frame carried them, in a flat `levels :: [{side, price, quantity}]` list rather
  than split into `bid_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. See
  `dp_exchange_core`'s `docs/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 for `WebSockex.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_timeout`
  behind 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 `quantity` of 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_up` pair
  brackets where a gap may fall; neither that notice nor anything else reconstructs a
  missing delta. The correct response to `:link_up` is to re-pull `get_order_book/2`
  (unaffected by this change) or accept the venue's own fresh `snapshot` on resubscribe —
  not to keep applying deltas across a gap. Coinbase's `level2` channel publishes no book
  sequence number, so `:sequence` on both types is always `nil` here.

  `Socket`'s `books` state, `apply_book_event/3`, `apply_book_row/2`, `update_level/4`,
  `remove_level/3`, `deliver_book/3` and `price_key/1` are all gone, along with
  `bench/order_book_resort.exs`, which benchmarked work that no longer exists.
  `Feed.payload_kind/1` now maps `%Types.OrderBookDelta{}` to `:order_book`, the same
  `data_kind()` a full `%Types.OrderBook{}` gets — `coverage_by_kind/1` answers "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/1` used to enforce (refusing a price with more than 8
  decimal digits) existed only to support the `:gb_trees` ordering key that mechanism
  needed — it was never an independent business rule. Sorting a snapshot's own rows via
  `Decimal.compare/2` needs 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-decimal `quote_increment` finding, 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 own `lib/`.**
  `DpExchange.Coinbase.query_transaction_validators/3`, `staking_status/4`,
  `unstake_status/4`, `claim_rewards/4` and `preview_unstake_wallet/6` now delegate
  straight to the matching `DpExchange.Coinbase.Prime` function, the same pattern
  `stake/3` and `unstake/3` already used for the other four. None of these map onto a
  `dp_exchange_core.Venue` callback — `staking_status` answers a narrower question than
  `get_staking_balances/1` would, `claim_rewards` is a write where that callback wants a
  read, and `query_transaction_validators`/`preview_unstake_wallet` have 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 as `Prime.X`"; that claim is now
  true through the facade as well, not only by reaching past it into an internal module.

- **`coverage_by_kind/1` implemented — Coinbase is the motivating case for
  `dp_exchange_core` 0.1.48's new optional callback.** `coverage/1` answers "is
  anything arriving for this symbol" by counting any payload at all, so a `level2`
  book update counted identically to a `ticker` quote. That blindness is not
  hypothetical: `level2` delivered upward of 11,000 frames across 406 subscribed
  symbols while `ticker` stayed dark on all but a handful, and `coverage/1` still
  answered `:stream` for all 406 — correct by its own definition, and exactly why
  DpCryptoManagement's issues #20 and #22 stayed unpinned for days.

  `Feed`'s `delivering` map now tracks `%{symbol => %{kind => timestamp}}` instead of a
  bare timestamp, keyed off which `Core.Types` struct actually arrived
  (`%Types.Quote{}` → `:quotes`, `%Types.OrderBook{}` → `:order_book`) — never off this
  venue's own channel names, which stay internal. `coverage_by_kind/1` on
  `DpExchange.Coinbase` and `DpExchange.Coinbase.Fake` both satisfy the union
  invariant `dp_exchange_core`'s conformance suite now checks whenever a venue exports
  this callback: the symbol keys across every kind exactly match `coverage/1`'s own
  keys, and every kind reported is one `capabilities().streamable` declares. The fake
  reports everything under `:quotes` only, honestly — `subscribe/2` never synthesises
  an order book, and claiming `:order_book` coverage it cannot back would be the
  "differently capable" divergence this fake's own moduledoc forbids.

  Bumped `dp_exchange_core` from `~> 0.1.36` to `~> 0.1.48` to pick up the callback.

- **`Fake` wired to `Core.FakeInjection` — DpCryptoManagement's issue #14.** Every
  function with a real success path (not an unconditional `Venue.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/1` and
  `close_position/3` support 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/2` and
  `update_symbols/2` are deliberately not wired — each takes a symbol list in one call,
  which whole-call injection cannot express partial failure for; neither is `coverage/1`
  or `subscribe_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 inspect `credentials` at all, an existing gap this wiring does not change.
  See `docs/design/2026-09-04_webull-sharding-and-fake-injection.md` §3.6/§3.7 in
  `dp-exchange-core`.

- **`get_market_overview/1` and `list_instruments/1` are implemented —
  DpCryptoManagement's issue #10.** Both sat behind `Venue.not_supported()`, one filed as
  a genuine venue absence (`@venue_does_not_serve`) with no per-item comment explaining
  why, against `get_symbols/1` already calling the exact bulk endpoint
  (`/products`/`/market/products`) that carries all of it. Live-verified: the response
  Coinbase actually returns names `price`, `price_percentage_change_24h`, `volume_24h`,
  `high_24h`, `low_24h`, `status` and `product_type` per product, and `get_symbols/1` kept
  only `product_id`. Both new functions read the same response `get_symbols/1` already
  fetches — via a shared `fetch_products/1` — rather than a second request.

### Removed

- **`Feed.pairs_per_socket/0` and `SymbolFormat.mapping/0` deleted — both were public
  getters over a module attribute with no caller anywhere in this package's own `lib/`,
  the other shape
  assertion 16 exists to catch.** `Feed`'s own moduledoc already says sharding details
  "never reach the facade" — `@pairs_per_socket` is consulted directly by `shards/1`,
  the function that actually shards, and stays; the accessor was consulted only by a
  test asserting the same number `shards/1`'s own tests already prove through behaviour.
  `SymbolFormat.mapping/0`'s doc claimed it existed "so the conformance suite can drive
  `CanonicalPair` with it," but `dp_exchange_core`'s conformance suite calls only
  `to_canonical_symbol/1` and `to_exchange_symbol/1` (the actual behaviour callbacks) —
  it never called `mapping/0`, and neither did anything else outside this package's own
  test suite. **Breaking**, for the two functions removed; neither was part of the
  `Venue` behaviour or documented as consumer-facing in `usage-rules.md`.

### Fixed

- **Corrected claim: a single oversized `level2` subscribe does not close the socket. It
  is refused wholesale, and the socket survives — which is worse to detect, not better.**
  Same issue #22. The `level2_pairs_per_socket` warning and
  `docs/reference/coinbase/level2-session-limit.md` used 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/4` for `n = 30` (accepted, `books=30`), then `n = 60` and `n = 120`
  (both REFUSED — `rate_limited`, `books=0`, nothing delivering), in both ascending and
  largest-first order; `Process.alive?/1` stayed `true` through a 40-second drain after
  every refusal, no `:DOWN` on 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_limited` `Core.Notice` `Socket`'s `error_kind/1` already
  emits — liveness looks perfect, and the oversized shard simply never delivers.
  `coverage/1` / `coverage_by_kind/1` are 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 with
  `Process.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
  `:kill` then killed the probe itself, because `Socket.start_link/1` links the socket to
  its caller — `Process.unlink/1` first was needed. Recorded, dated and attributed, in
  `docs/reference/coinbase/level2-session-limit.md`.

  `lib/dp_exchange/coinbase/feed.ex`'s moduledoc (the `level2_pairs_per_socket` section
  and its `validate_level2_pairs_per_socket!/1` warning text), `usage-rules.md`, and
  `docs/reference/coinbase/level2-session-limit.md` are all updated; no code behaviour
  changed, only the documented and logged claim. No new `Core.Notice` was added:
  `Socket`'s existing `:rate_limited` notice already names the venue's own refusal
  message verbatim, which already carries this specific cause — a second notice would
  duplicate one already firing.

- **`Socket` full-re-sorted BOTH sides of the maintained level2 book on EVERY `l2_data`
  frame, including an `update` changing a single price level** —
  `dp_exchange_core`'s `docs/design/2026-09-06_order-book-resort-cost.md`. Measured at
  the book size DpCryptoManagement reported live for `BTC-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 why
  `ticker` starves whenever `level2` is delivering broadly in #22, since the
  single-threaded socket could never idle long enough to service the `ticker`
  subscribe inside `FrameSender`'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_trees` tree keyed by the price
  scaled to an exact integer (`10^8` — verified 2026-09-06 against Coinbase's own
  public `GET /api/v3/brokerage/market/products`: the smallest published
  `quote_increment` across all 931 products is `0.00000001`, 8 decimal places, and no
  product's own `price` field carries more precision than that either), carrying the
  original `Decimal` as the tree's value. Delivery is now an ordered traversal —
  `Enum.sort_by/3` no 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. Run
  `mix run bench/order_book_resort.exs` to reproduce on any machine.

  A price that cannot be represented exactly at that scale is refused and reported
  through the same `:data_quality` notice 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 when `Decimal.equal?/2` says 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, `Decimal` values, count, the
  `Core.Types.OrderBook` struct itself — is unchanged, and is asserted so against a
  reference reimplementation of the old approach in `socket_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 default `alias_map_source` called `Rest.get_alias_map/1` without
  `rate_limit_blocking: true`, so it went through fail-fast `check/3` instead of blocking
  `acquire/3`. The fetch is scheduled off the first `subscribe/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_map` stayed `%{}` for
  the life of the process. Measured live: 406 pairs requested as `-USDC`, delivered as
  `-USD`, overlap 5 — `coverage_by_kind/1` and 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, while `Core.HttpClient`'s own error
  message names the fix in its text ("callers that can wait should set
  `rate_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`:

  1. `rate_limit_blocking: true` is now set unconditionally on the fetch, by a new
     `default_alias_map_source/2` that also forwards `:limiter`, `:plug`, `:timeout`,
     `:retry_attempts`, `:retry_delay` and `:weight` from `start_link/1`'s own `opts` —
     the same allowlist shape `Rest`'s own request pipeline uses.
  2. 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?/1`
     classifies 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, matching `transient_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.
  3. **The degraded-attribution notice was unreceivable by construction, also part of
     issue #26.** The fetch is scheduled from `subscribe/3`; a consumer calling
     `subscribe_notices/1` afterward — 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, with
     `next_resubscribe_delay/1`'s per-tick storm): `alias_map_status` and the reason that
     produced it now persist in state, and `subscribe_notices/1` replays 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.exs` drive the real fetch pipeline end-to-end for the first
  fix — a real, named `Core.DefaultRateLimiter` with its sole token already spent, behind
  a fake `:plug` response, proving `rate_limit_blocking: true` actually reaches
  `Core.HttpClient` rather 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 default `alias_map_source` codepath or a
  controlled stand-in, none against a guessed `Process.sleep/1` duration.

- **An empty `pricebooks` array from `/best_bid_ask` was 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
  because `Core.PollingFeed` reports 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 answers `404 {"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 *different* `404
  {"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_ask` takes a *list* of `product_ids` and
  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 existing `classify/1` path, which
  this change does not touch. New tests in `rest_test.exs` and `order_book_test.exs` cover
  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 a `send_frame` exit 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 into `Feed`. A
  `level2` subscribe triggers a full per-symbol book snapshot; the socket is
  single-threaded and cannot service the next `send_frame` while decoding it, so firing
  `ticker`'s subscribe `@channel_spacing_ms` later 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 `level2` got through broadly, its opening snapshot burst starved `ticker`; when
  the venue refused most `level2` subscriptions outright (its own per-session stream
  limit — see the "sharded" section above), `ticker` had the socket to itself and got
  everything. A lone `:send_timeout` on a `ticker` subscribe 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_ms` rather 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 a `Core.Notice` of kind `:coverage_change` in addition to the existing log — a
  channel that never subscribed is exactly the invisible half-dead feed this issue is
  about, and a `Logger.warning` alone 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: `@channels` order (`level2` before `ticker`) and
  `@channel_spacing_ms` itself. 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_blocking` was unreachable on every REST call this package makes —
  family-wide gap, DpCryptoManagement's issue #23.** `Core.HttpClient.check_rate_limits/1`
  reads this option to choose `acquire/3` (wait for capacity) over fail-fast `check/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/5` and
  `Prime.request_opts/1` all stripped it from their forwarded-options allowlist before it
  ever reached `Core.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 of `acquire/3` / `check/3` was actually called — not merely
  that the keyword survives the allowlist. **Not defaulted anywhere in this package**,
  unlike `dp_exchange_webull`'s `Feed` and `dp_exchange_robinhood`'s `Feed`: 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_ms` apart and each shard's channels `@channel_spacing_ms`
  apart, so the last frame goes out about `(shards - 1) * 5_000 + 8_000` ms after the
  tick. If the timer re-fired before that, cycles overlapped, frames queued behind each
  other, `WebSockex.send_frame/2` blew its window, and the `Feed` stopped answering calls
  entirely — `:sys.get_state/1` timing 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/2` could never work without credentials, and the facade said
  otherwise — family-wide defect sweep, Coinbase B1.** Unlike every sibling market-data
  reader in `Rest`, this one call is hardcoded to `/best_bid_ask` with no
  `/market/best_bid_ask` branch. Re-verified live 2026-09-05: authenticated is `401`, and
  the public path a caller would expect by analogy is `404` — 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's `401` as an opaque error. `DpExchange.Coinbase`'s moduledoc and
  `capabilities/0`'s `credential_benefit` comment 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.md` carried 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/2` silently dropped a `level2` row it could not parse — family-wide
  defect sweep, Coinbase B2.** Every other decode-failure path in `Socket`
  (`deliver_ticker/3`, `deliver_book/3`) reports a `:data_quality` notice through
  `report_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/3` now threads `state` through and reports a `:data_quality` notice for
  an unparseable `price_level`/`new_quantity` and for a row missing those keys entirely —
  the connection is still never torn down over one bad row.

- **`Socket.start_link/1` inherited WebSockex's own connect/recv timeouts by accident —
  family-wide defect sweep, Coinbase B4.** No `:socket_connect_timeout` or
  `:socket_recv_timeout` was set, so WebSockex supplied its own defaults — measured in
  the vendored dependency, `deps/websockex/lib/websockex/conn.ex:10-11`: `6_000` ms
  connect, `5_000` ms recv. That matters specifically because `Feed`'s `open_shard/5`
  synchronous branch calls `Socket.start_link/1` from **inside** a `handle_call/3`, and
  `Feed`'s own `@call_timeout` is `@frame_window_ms * 3` = `15_000` ms — a named, shared
  process, so every other consumer's `subscribe/2`, `unsubscribe/2`, `update_symbols/2`
  and `coverage/1` call queues behind that one call. The inherited defaults alone
  (`6_000 + 5_000 = 11_000` ms) 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_000` ms each (`6_000` ms total), chosen
  deliberately against `Feed`'s `15_000` ms budget — leaving roughly `9_000` ms of the
  same call for the socket to send at least one subscribe frame (capped at `Feed`'s own
  `5_000` ms `@frame_window_ms`) plus ordinary `GenServer` overhead. No failure
  semantics changed: `start_link/1` still 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 in
  `opts` still overrides it. The merge is factored into a small `@doc false`
  `connection_opts/1` so 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`: subscribing `ticker` to
  `["XLM-USDC", "AVAX-USDC"]` — sent exactly as asked, both real, listed products —
  delivers every frame tagged `XLM-USD` and `AVAX-USD` instead; 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/products` catalogue this package already reads for `get_symbols/1` and
  `list_instruments/1` names it directly — on this date, 112 of the first 114 USDC
  products carried a non-empty `alias` naming their `-USD` counterpart. 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`, not `Socket`: `Socket` still decodes and delivers under whatever
  `product_id` the venue actually sent, unchanged. `Feed.handle_info({:dp_exchange,
  :coinbase, payload}, state)` now resolves a delivered id against `Rest.get_alias_map/1`
  — the venue's own declared relationship, fetched **once**, asynchronously, the first
  time `subscribe/3` or `update_symbols/2` runs (never per frame, never per subscribe;
  see `Feed`'s moduledoc for why it is not read from `init/1` or inline in the
  triggering call) — and delivers under every name in `wanted` that 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/1` needed 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_quality` notice naming the failure, that attribution is degraded and
  why. Munging `-USDC` into `-USD` was 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 own `alias` field.

  Regression tests in `feed_test.exs` drive the proven mechanism directly — a subscribe
  to the alias form receiving frames tagged with the canonical form delivers under the
  alias form; `coverage/1` lists 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 — and `rest_test.exs` covers `get_alias_map/1`
  itself against a catalogue shaped like the live response captured while proving this.

- **`Supervisor`'s own rate limiter was configured from `public_ceiling` unconditionally,
  contradicting `capabilities/0`'s own documented promise that credentials buy the higher
  ceiling.** `capabilities/0` states outright: *"Pass credentials and this package uses
  the authenticated path, which has the higher ceiling."* `Rest`'s request paths honour
  that — but `Supervisor`'s `init/1` started `DefaultRateLimiter` from `caps.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/1` now reads `opts[:credentials]`
  (the same opts `Feed` reads it from) and configures the bucket from
  `authenticated_ceiling` when a non-empty credential map was given, `public_ceiling`
  otherwise. New tests in `supervisor_test.exs` prove 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 a `Logger.warning` that never crosses it.** Every shard beyond the
  first opens asynchronously; when its `Socket.start_link/1` failed (a transient connect
  refusal, a timeout), `handle_info({:open_shard, _, _}, _)` logged and stopped, leaving
  those symbols silently absent from `coverage/1` with 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_change`
  notice for the identical shape of fact (subscribed intent that did not become
  delivery). Now emits one, through the same `notify_shard_open_failed/3` path.

  **Worse: a shard that failed to open had no automatic recovery path at all.** The
  unconditional `:resubscribe` tick only ever walked `state.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 fresh `subscribe/3` or
  `update_symbols/2` call, which may never come for a consumer whose scope is stable
  after boot. `retry_missing_shards/1` closes this: on every `:resubscribe` tick, any
  shard `state.wanted` still implies but `state.shards` has 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 in `feed_test.exs`
  cover 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/2` call
  dropped the stagger between them — only a brand-new shard's connect was staggered.**
  `reshard/1` computes `position * @shard_spacing_ms` for every shard beyond the
  synchronous primary and hands it to `touch_shard/4`, but `reconcile_shard/6` (an
  existing, already-open shard) never received it — every already-open shard a single
  call touched had its `level2` subscribe scheduled at the identical instant regardless
  of position. This is not the connect burst `@shard_spacing_ms` was written against (no
  new socket opens here), but a related hazard: `Socket.subscribe/4` blocks THIS `Feed`
  process — via `FrameSender`, up to `WebSockex.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 stalling `coverage/1` and every other call to this `Feed` for as long as the
  slowest one takes. `reconcile_shard/7` now receives and applies the same `delay`
  `open_shard/5` already did. A new test in `feed_test.exs` proves it with two live
  sockets recording arrival time: touching two already-open shards in one call now
  delivers their first frames `@shard_spacing_ms` apart, not together.

- **`Fake`'s `get_top_of_book/2` ignored `opts[:credentials]` entirely,
  making the fake MORE capable than the real venue on the one call where that gap
  matters.** `Rest.get_top_of_book/2` refuses `{:refused, :missing_credentials}` before
  sending anything when given none — `/best_bid_ask` has no public form on this venue,
  confirmed live (`401` authenticated, `404` at 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 in `fake_test.exs` and an updated one in
  `fake_injection_test.exs` (which previously called it with no credentials at all and
  got away with it) cover both branches.

- **`level2` and `ticker` shared one shard size, and the venue's own `level2` ceiling is
  well under it — DpCryptoManagement's issue #22 continuing, not reopened.** The `ticker`
  starvation 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: `@channels` order... 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/406 `quotes` coverage): `shards/1` at the old shared
  `@pairs_per_socket` (100) split it `[100, 100, 100, 100, 6]`, and `level2`'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/1` answered `order_book: 6` throughout: exactly the tail shard's own
  count, pinning the cause on shard size rather than the connection, the alias fix, or
  `ticker` (which has no such ceiling and was unaffected on the same boots).

  No Coinbase documentation states a per-session `level2` product ceiling — re-checked
  2026-09-06 against the Advanced Trade channels reference, connection overview and
  rate-limits page (which states an `8`-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:
  `level2` is 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.

  `level2` now gets its own shard grouping, at its own, independent size —
  `@level2_pairs_per_socket`, `6` — rather than sharing `@pairs_per_socket` (100, still
  `ticker`'s own size, unchanged) with `ticker`. `6` is not a rediscovered venue limit;
  it is the largest `level2` subscription 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, 5 `ticker` sockets (unchanged) plus 68 `level2` sockets
  (`ceil(406 / 6)`), 73 total against 5 before, affordable now that removing in-package
  `level2` book maintenance (the change above this one) cut per-frame decode cost roughly
  tenfold.

  Every new socket — either channel — is staggered on one `@shard_spacing_ms` sequence
  with every touched `ticker` shard ordered ahead of every touched `level2` shard, so
  `ticker`'s own boot-time coverage stays exactly as fast as before this change while
  `level2`'s far more numerous shards ramp in behind it — for the 406-symbol universe,
  roughly six minutes for the last `level2` shard, against a ceiling that previously never
  moved at all. `@channel_spacing_ms` — the wait between `level2` and `ticker` sharing 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_ms` keeps its value (still `8_000`ms) 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`'s `error_kind/1` already classifies "too many" as `:rate_limited` and
  reports it as a `Core.Notice` on every occurrence, and `coverage_by_kind/1` already
  never marks a symbol covered for `:order_book` on subscribed intent alone. Both are
  verified unchanged by this fix. If `6` is ever also refused, a consumer with
  `subscribe_notices/1` wired 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.exs` pin the structural fix: a credentialed feed given a symbol
  count that fits in one `ticker` shard splits it into two `level2` shards, with the
  `ticker` shard chosen as the call's synchronous primary; a credential-less feed never
  opens a `level2`-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_ms` term —
  both mechanical consequences of this change, not new behaviour of their own.

- **`@level2_pairs_per_socket` was `6` — a conservative lower bound, correctly labelled as
  one — and the real boundary is now measured: `30`. Same issue #22, continuing.** `6` was
  never a rediscovered venue limit; it was the largest size this package had any positive
  evidence for at the time, with `100` (refused) as the nearest known failure and nothing
  in between actually tried. This package still cannot bisect a live, authenticated
  `level2` session 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/30` accepted, `n = 31/35/50/100` refused, confirmed by interleaving two
  runs back to back and by a contamination check (re-running `n=6` and `n=30` immediately
  after a refusal run, both still accepted, ruling out probe-induced saturation as the
  cause). `30` is the largest value with positive evidence of acceptance, `31` the
  smallest with positive evidence of refusal — the boundary itself, not merely a
  known-good far below a known-bad. See `docs/reference/coinbase/level2-session-limit.md`
  for the full method and attribution.

  For DpCryptoManagement's 406-symbol universe this is 14 `level2` sockets instead of 68
  (19 total instead of 73) and roughly 70 seconds to full `order_book` coverage 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/7` used to subscribe
  a shard's newly-added symbols onto whatever socket that shard already had open, and a
  `MapSet`'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-open `level2` shard products it has never carried before. At `6` this
  had enormous headroom; at `30`, 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 a `level2` shard'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, and `ticker` is 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_ms` comment already
  named before this investigation, and this investigation did not close it. Two specific
  probes that would are recorded in `docs/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_limited` notice rather
  than refusing outright, once even at `n=31`, the smallest over-the-boundary value. This
  does not move the boundary (every `n ≥ 31` refused, every `n ≤ 30` did not) and has no
  explanation from either party; it is recorded, dated and attributed, as an unexplained
  venue characteristic in `docs/reference/coinbase/level2-session-limit.md`, not
  rationalised into a theory neither party has evidence for. This package should never
  itself trigger it — every `level2` subscribe it sends carries at most 30 symbols by
  construction, before and after this fix — and `coverage/1` / `coverage_by_kind/1` need
  no change to stay honest if it ever does: both are built entirely from symbols that
  actually delivered a payload, and a `Core.Notice` never touches that bookkeeping.

  New tests in `feed_test.exs` pin both the number and the fix: `35` symbols (rather than
  `10`) now produces two `level2` shards at `30`/socket; a `level2` shard 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; `ticker` given 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. `9139881` replaced a growing `level2` shard'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 not `Feed`/`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=0` on 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**, and `unsubscribe` releases budget the venue
  actually honours.

  `reconcile_shard/7`'s `"level2"` clause and `replace_level2_shard/7` are gone.
  `reconcile_shard_in_place/7` now handles both channels, all three shapes a shard's
  membership can change (only losses, only gains, both), on the shard's EXISTING socket:
  `removed` is unsubscribed before `added` is 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` — with `added` withheld, 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 by
  `max(length(current), length(wanted))`, never more than `level2_pairs_per_socket`,
  including mid-reconcile, not only at rest.

  **What guarantee this package actually has is stated plainly, not assumed generous.**
  `Socket.subscribe/4` and `Socket.unsubscribe/3` both block on `FrameSender`'s synchronous
  `WebSockex.send_frame/2` call, 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. But
  `Socket.unsubscribe/3`'s `:ok` means 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 in `feed.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 plain `subscribe` —
  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.md` records 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:** the `level2_pairs_per_socket` warning 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.exs` replace the socket-replacement suite: a growing `level2`
  shard 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_change` notice 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/7` recorded `state.shards[key].symbols = wanted`
  unconditionally on both its sync and deferred clauses, regardless of whether the
  underlying unsubscribe ever succeeded. The WITHHELD `added` side already recovered —
  the 60-second cycle re-issues `state.shards[key].symbols`, which is `wanted` — but the
  STRANDED `removed` side 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 at
  `old ∪ 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 the `added` recovery 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 by `strand_unsubscribe/7` whenever a reconcile gives up on releasing some
  `removed` symbols — 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 by `handle_info(:resubscribe, _)` on the identical unconditional
  cadence `retry_missing_shards/1` already 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 as `removed`, its WHOLE
  current membership (not a delta) as `added` — through the SAME `attempt_channel_reconcile/6`
  an 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
  plain `Socket.subscribe/4` already cost, since `unsubscribe_step/3` short-circuits an
  empty list without sending anything.

  `clear_pending_unsubscribe/3` removes 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/7`
  is symmetric — it merges, never overwrites. `drop_unwanted_shards/3` now also drops a
  shard's pending entry the moment the shard itself is dropped, since
  `handle_info(:resubscribe, _)` walks `Map.keys(state.shards)` specifically and would
  otherwise retry nothing for a key that no longer exists there — this is what keeps
  `state.pending_unsubscribes` bounded 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:
  `added` is 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
  `Socket` holds no shard-correlating identity in its `:link_up`/`:link_down` notices by
  design (its own moduledoc: "`Socket` holds no book to key by anything"), and WebSockex's
  reconnect keeps the same pid, so pid identity cannot substitute. Building that
  correlation would widen `Socket`'s own contract for a case whose cost, left alone, is
  bounded: a stale entry retried against a fresh session sends one wasted `unsubscribe`
  (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/3` builds 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_timeout` and `{:send_exit, reason}`, are both already classified transient. The
  non-transient `cond` branch in `handle_unsubscribe_failure/8` therefore 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?/1` staying `true` throughout — 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"
  reason `handle_subscribe_failure/6` keeps its own — a two-branch `cond` reusing 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-before`
  rate-limit headers; it deliberately does not, and never has.** Those are pagination
  cursors, not rate-limit data, and Coinbase publishes no `x-ratelimit-*` or
  `retry-after` either — 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 in
  `docs/reference/coinbase/reconciliation.md` §5.5, which CLAUDE.md's own claim
  contradicted. Corrected to state what actually happens: nothing is parsed, and
  `Core.HttpClient`'s generic parser correctly answers `nil`.

- **`docs/reference/coinbase/endpoint-inventory.md` still listed `/best_bid_ask` and
  `/product_book` as not implemented — family-wide defect sweep, Coinbase B3.** Both were
  implemented and declared `:experimental` in `capabilities/0` well 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_ask` went 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_ask`
  has no public form, verified live 2026-09-05, unlike `/product_book`, whose
  `market/product_book` twin is real and public.

- **`usage-rules.md`'s "Streaming" section never said which symbol a delivered frame
  carries, and never mentioned `resubscribe_interval_ms` at 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_ms` has been a real `Feed`
  option, 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_quality` notice; one documenting
  `resubscribe_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 `:experimental` and
  41 `:unsupported`" with "38" of those the venue's own absence. Run against the real
  `capabilities/0` (`mix run -e`, 2026-09-05): **48 `:experimental`, 39 `:unsupported`**,
  of which **37** are `venue_does_not_serve/0` (the other 2 are `@not_ported`,
  `get_funding/2` and `get_contract_stats/2`). Corrected to the measured numbers rather
  than re-guessed.

- **`frame_sender.ex`'s moduledoc claimed `WebSockex.send_frame/2` has "no way to
  override" its 5-second timeout.** The vendored websockex 0.5.1 exposes `send_frame/3`
  with a timeout argument, so the claim was wrong. `FrameSender.send/3` still 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 `ticker` frame from the real venue failed to decode — 0 `Quote`s 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
  `-USD` symbols captured 500+ consecutive `data_quality` notices and zero `Quote`s in a
  20-second window. `build_quote/2` read `ticker["time"]` — a field that does not exist
  on the row. Confirmed against Coinbase's own CDP API reference for the `ticker`
  channel, independently, twice: the timestamp lives on the *message envelope*
  (`"timestamp"`, one per frame), never on the individual `tickers` row. 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/2` now reads the envelope's own `timestamp`
  and threads it down to `build_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/2` didn't read *any* venue timestamp — it substituted
  `DateTime.utc_now/0` unconditionally, 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/3`
  now reads the same envelope `timestamp` and fails closed if it's absent, same as
  `build_quote/3` — the maintained book state still updates either way, only the
  outgoing delivery is withheld.

  **Does not, on its own, explain why `level2`/`OrderBook` delivered zero data in any of
  the three live tests run while chasing #22** — the old `DateTime.utc_now/0` fallback
  always succeeded, so this was never why level2 was silent there. That remains open.

- **A `level2` capacity 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/2` collapsed both into `:credentials_rejected` — the shape the
  original stub-token incident produced — which sent a consumer that finally wired
  `subscribe_notices/1` looking 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_rejected` behavior.

  **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 `level2`
  session 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/1` scheduled every shard past the synchronous first one with the
  *same* fixed `@shard_spacing_ms` delay 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 turn `position * @shard_spacing_ms`
  after 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/2` crashed on a subscriber registered by name — DpCryptoManagement's
  issue #15.** `subscribe/2`'s `to:` option accepts any value, and `fan_out/2` called
  `Process.alive?/1` on 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 to `to:` crash-looped the whole `Feed` GenServer 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 answered `WebSockex.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. Full
  `feed_test.exs` run time: ~45s → ~3s.

- **`to_order/1` read both `Order.quantity` and `Order.filled_quantity` from the same
  venue field — DpCryptoManagement's issue #12.** `order["filled_size"]` populated both,
  so a fetched order's `remaining_quantity` (quantity minus filled) was always zero, even
  for a genuinely open, partially-filled order — a correctness bug for anything
  reconciling open-order state. `quantity` now reads the venue's own record of what was
  requested, from `order_configuration`'s leaf `base_size` — the same field
  `closing_configuration/1` already reads for a closing order's size, on the same
  response envelope. A quote-sized market order's leaf carries `quote_size` instead, with
  no rate here to convert it, so `quantity` is `nil` rather than a guess in that case.

### Added

- **`level2` is subscribed and decoded — `streamable` gains `:order_book`.** The channel
  was recognised and had working auth machinery since an earlier release but was never
  actually requested; `capabilities/0` said `[:quotes]` while the code that would have
  served `:order_book` sat unused. `Socket` now maintains a real per-symbol book —
  snapshot then patched by `update` deltas, `new_quantity: "0"` removing a level — and
  delivers `Core.Types.OrderBook` sorted 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 `level2` subscribe 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. `Feed` now opens one socket per 100
  symbols (the number from that incident, carried over rather than re-derived), spaced
  to avoid a connect burst, `level2` subscribed before `ticker` on each and the two
  spaced apart so a snapshot decode in progress does not turn a `ticker` subscribe into
  a `send_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. `Feed` re-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.

- **`level2` is skipped for a credential-less subscriber rather than failing loudly for
  no reason.** It requires a credential and `ticker` does not; a caller with no
  credentials only ever wanted the public channel, and sending a doomed authenticated
  subscribe would either surface `credentials_required` as this call's synchronous
  result — masking that `ticker` works fine — or cost a wire round trip to learn what
  the credential's absence already answers.

### Documentation

- **The `:unsupported` list is now split.** `venue_does_not_serve/0` names 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_ported`
  because 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.md` states what the contract covers** — 46 of 87 callbacks `:experimental`, and
  points at `negative-claims.md` for 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: false` and `supports_order_replace: false` were 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. And
  `get_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.md` gains 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.md` gains a pointer** to this package's own `usage-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`**, and `place_orders/3` is 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 calls `place_order/3`
  several times and reconciles the outcomes itself.

### Added

- **Key permissions and the server clock** — `get_roles/1`, `get_server_time/1` and a
  `test_connection/2` that is no longer declared absent.

  **`can_transfer` is a separate permission from `can_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/2` asks 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/1` returns 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_at` is `nil`, 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/2` and even `get_conversion/2` **re-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 to `nil`, never the nearest one.

  **A portfolio is an address, not a value.** `list_portfolios/1` returns them,
  `get_portfolio_breakdown/3` returns what is *inside* one — a different and much larger
  answer — and `create_account/1` and `rename_account/3` reach 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/2` was 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, in `volume_breakdown` per volume type with `advanced_trade_only_volume` and
  `coinbase_pro_volume` beside 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/2` carries **both** `fee_tier` and `fee_tier_without_promotion` — they differ
  while a promotion is running, and it can end between two calls — and keeps the tax's
  `INCLUSIVE`/`EXCLUSIVE` flag, because the same rate quoted either way is a different amount
  of money.


- **US derivatives — the nine CFM endpoints.** `get_positions/1` and
  `list_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_balance`
  is the first, `cbi_usd_balance` the second, `total_usd_balance` the pair — and a caller
  sizing a futures position against the total is sizing against money that is not there.
  Every amount keeps its `currency`; flattening it off is how two currencies get added.

  **`:realised_pnl` is `nil` on a `Types.Position` from this venue, and that is not an
  omission.** Coinbase publishes `daily_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/1` returns the
  venue's own row, where it keeps its own name, along with `expiration_time`, which
  `Types.Position` has no place for either because a future expires and a perpetual does not.

  **A sweep is scheduled, not settled.** `schedule_futures_sweep/2` queues a move out of the
  futures account and `list_futures_sweeps/2` reports 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/2` cancels *the* pending sweep and takes no id.

  **`INTRADAY_MARGIN_SETTING_UNSPECIFIED` is 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:
  `UNSPECIFIED` is 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/2` carries 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_types` gains `:future`. `:perp` stays absent: Advanced Trade's
  perpetuals are the INTX endpoints, which are `APPROVED-SKIP` as 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,
  with `stake/3` and `unstake/3` now live on the facade.

  **A different product, host and signing scheme.** Everything else in this package talks to
  `api.coinbase.com/api/v3/brokerage` and signs a CDP JWT; Prime talks to
  `api.prime.coinbase.com/v1` and 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/3` would 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/3` and
  `unstake/3` follow only what the caller said — a `:wallet_id` means the wallet, its
  absence means the portfolio — and `opts[: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/status` names one wallet and is not
  "every staked position, one per asset" (reachable as `Prime.staking_status/4`); and
  `claim_rewards` is 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.StakingBalance` built 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}`) and
  `transfer_internal/4` (`POST /portfolios/move_funds`).

  **A payment method's flags disagree with each other.** Each row carries `verified`,
  `allow_deposit` and `allow_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/3` is 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/4` moves 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, since `Decimal.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/2` is absent for a different reason worth stating.**
  `/transaction_summary` exists 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**, and `Rest.get_product/2` for
  the whole record. Both were `:unsupported`.

  **The venue names four increments and they are not interchangeable.** `quote_increment`
  bounds the *price* and `base_increment` the *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_size` is units and `quote_min_size` is cash, and a
  market order sized in cash is bounded by the second where a limit order in units is
  bounded by the first.

  `status` is 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/1` reads 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/2` already reads this payload and keeps
  only the newest print, because a `Quote` has 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. `broken` is `false` on
  every print — the ticker publishes no bust flag, and a venue with nothing busted reports
  nothing busted.


- **`get_historical_prices/4` reads 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_book` for a credential and `/market/product_book` without 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's `limit` and
  `aggregation_price_increment` are 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. `sequence` stays `nil` — the endpoint publishes none, and a
  caller must not learn to detect stream gaps from a REST book.

### Fixed
- **`get_top_of_book/2` now carries the sizes.** It read `/products/{id}/ticker`, which
  publishes `best_bid` and `best_ask` and nothing about how much is there — so `bid_size`
  and `ask_size` were `nil` on every response.

  That `nil` was 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, and `nil` gave it no way to ask.

  An empty side is still `nil` rather 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_type` is not decoration.** Regular fills carry `FILL`; the venue also emits
  `REVERSAL`, `CORRECTION` and `SYNTHETIC` for adjusted ones, and a reversal is not a trade
  that happened. `Core.Types.Fill` has 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 **only `FILL` rows by default**, and `opts[: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_currency` is `nil` rather than the pair's quote guessed from the symbol — a fee can
  be charged in a third asset and often is. `UNKNOWN_LIQUIDITY_INDICATOR` maps to `nil`,
  because neither `:maker` nor `:taker` is 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 `cursor` to a page bound.


- **`get_balances/2` and `get_accounts/2`.** The package could not say what the credential
  holds.

  **The venue reports `available_balance` and `hold` and no total.** The total here is
  their sum — arithmetic on two numbers the venue stated, not an estimate — and it is `nil`
  when 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_pages` bounds it, so a server that always says `has_next` errors
  rather than looping inside a facade call.

  `get_accounts/2` is 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.

  `:timestamp` is when the request was made — a balance has no venue event time.


- **`convert/4` and `get_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 is
  `quote_conversion/4` and friends, scheduled separately. The one-step `POST /conversions`
  belongs to the **Exchange** API, a different product this package does not reach.
  `/products/volume-summary` is market volume and lives there too; `get_trade_volume/2`
  asks what *this account* traded, which Advanced Trade does not aggregate.

- **`preview_replace/4` and `close_position/3`.** Two documented endpoints this package had
  no facade for.

  `POST /orders/edit_preview` prices an amendment before it is made. It is not
  `preview_order/3` with 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 carries
  `average_filled_price` and `order_margin_total` — numbers a fresh order does not have.
  It takes the same `:price` / `:quantity` change set `replace_order/4` does and refuses
  anything else before the request.

  `POST /orders/close_position` flattens a position by having the venue place the closing
  order. **The returned `Order` carries no side.** The venue never states one, and it
  worked the side out from a position this package did not read — filling in `:sell`
  because closing is usually selling is wrong exactly where it matters, on a short. The
  order type, time in force and size *are* read, from the `order_configuration` the venue
  echoes back, and a configuration key this package does not recognise leaves them `nil`
  rather than picking the nearest.

### Fixed

- **`cancel_all_orders/2` is declared unsupported, with the reason checked.**
  `POST /orders/batch_cancel` takes an explicit `order_ids` list — it is the endpoint
  `cancel_order/3` already uses, one id at a time. There is no "cancel everything" call
  here, and assembling one from `get_orders/2` plus 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/4` returns `Core.Types.Candle`. It was returning
  `Quote`s with `price: 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 `price` was 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`'s `Quote`, 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_timestamp` rather
  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_preview` and `supports_order_replace` were declared `false` on
  those claims, and neither was checked against the venue's reference. Coinbase publishes
  `POST /orders/preview` and `POST /orders/edit`; both flags are now `true` and 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/3`** builds the same `order_configuration` as `place_order/3`, so a
  preview is a preview of the order that would actually be sent. **A `200` carrying a
  populated `errs` is a refusal** — returning it as a successful preview would tell a caller
  its order is fine when the venue has already said otherwise. A `warning` is passed through
  and does *not* make it a refusal.
- **`replace_order/4`** edits 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_cancel`
  answers with a `results` array carrying its own `success` and `failure_reason` per id, so
  a `200` says 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_QUEUED` maps 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/2` filters 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 no `limit_limit_ioc`, no `market_market_gtc`.

  **A pair the venue does not name is refused before the request is sent.** Sending
  `{:limit, :ioc}` as `limit_limit_fok` would 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_only` is omitted
  when unset rather than sent as `false`, because silence is not a decision to take
  liquidity.

  A `200` carrying `success: false` is a **refusal**, not a placed order.

  `client_order_id` is 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.tsv` and 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 no `deribit` in 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
  `553fa787` with its working-tree state recorded, since the Coinbase subtree was dirty
  at extraction time.
