# 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 — pin three-part (`~> 0.3.0`). Coverage is uneven by design: fakes and
live public endpoints are well covered, order placement and authenticated flows are
not.

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

## [Unreleased]

## [0.3.120] - 2026-10-21

### Added

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

### Changed

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

### Documentation

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

## [0.3.119] - 2026-10-21

### Fixed

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

## [0.3.118] - 2026-10-21

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

### Added

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

### Changed

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

### Fixed

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

### Tests

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

## [0.3.117] - 2026-10-20

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

## [0.3.116] - 2026-10-20

### Fixed

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

### Changed

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

### Documentation

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

## [0.3.115] - 2026-10-20

### Documentation

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

## [0.3.114] - 2026-10-20

### Fixed

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

## [0.3.113] - 2026-10-20

### Documentation

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

### Added

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

### Fixed

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

## [0.3.112] - 2026-10-19

### Documentation

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

## [0.3.111] - 2026-10-19

### Fixed

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

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

### Documentation

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

## [0.3.110] - 2026-10-19

### Documentation

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

## [0.3.109] - 2026-10-19

### Fixed

- **`PollingFeed` refuses a duration `Process.send_after/3` cannot arm.** `:interval_ms`,
  `:start_delay_ms` and `:fetch_timeout_ms` above 4_294_967_295 passed the integer check and
  raised from a timer: at `init/1` for the first two, and for `:fetch_timeout_ms` on the
  FIRST FETCH inside `handle_info/2`, where the restart lost every runtime `update_symbols/2`
  scope and raised again. All three now stop the feed at start with
  `{:invalid_option, key, value}`.
- **`HttpClient.request/5` refuses a `:retry_delay` whose longest retry wait no process can
  sleep.** The wait is `retry_delay * n` plus a quarter, and `Process.sleep/1` raises past
  2^32 - 1 ms; a delay of 2_000_000_000 with the default three attempts sent the request and
  raised in the calling venue process on the first retry. It is an `ArgumentError` before
  anything is sent, like the other retry options. One attempt never sleeps and is unaffected.
- **`DefaultRateLimiter.acquire/3` answers `{:error, :rate_limit_timeout}` for a wait over
  2^32 - 1 ms, `timeout: :infinity` included.** The sleep happens after the reservation is
  committed, so a weight large enough to ask for years raised in the caller with the tokens
  already spent. The reservation is now not committed.
- **`Types.OrderBook.new/1` refuses a level with a price or a size at or below zero**, and
  `Types.OrderBookDelta.new/1` a price at or below zero or a negative quantity (zero stays: it
  is how a delta removes a level). `OrderBook`'s moduledoc already said a negative size or a
  price `<= 0` refuses the whole book and a zero size is dropped; only shape and order were
  checked.

- **`Types.*.new/1` hold the value rules each type's own doc states** (they were prose only;
  a struct literal still builds anything). `Candle.new/1` refuses an incoherent bar (high
  below low, or open or close outside `[low, high]`). `Quote.new/1` and `Trade.new/1` refuse a
  price at or below zero; `TopOfBook.new/1` a bid or ask at or below zero (a venue's `0` is
  `nil`); `Position.new/1` a quantity at or below zero. `Fill`, `Balance`, `Conversion`,
  `DepositAddress` and `ApprovedAddress` refuse a blank or non-text identifying field (order
  id, currency, asset, network, address); `DepositAddress.memo_required` is `true`, `false`
  or `nil`. `Withdrawal` needs an amount above zero, a fee not negative, and a `:tx_id` when
  `:broadcast`. `StakingTransaction.amount` is above zero and its progress, with
  `StakingBalance`, `StakingRate` and `StakingReward` amounts, not negative; a reward's
  `accrual_count` is a non-negative integer or `nil`. `OptionContract` needs a strike and
  multiplier above zero, `Date` expiries and boolean-or-`nil` flags; `OptionChain` keys its
  expiries on `Date`s; `OptionGreeks`, `ContractStats`, `VolumeProfile` and `FxRate` refuse
  negative open interest, volatility, volumes and non-positive rates and marks. `NewsItem`
  and `Watchlist` `symbols` are `nil` or a list of strings; `FinancialStatement.line_items`,
  `CorporateEvent.details` and `ScreenerResult.metrics` are maps; `ScreenerResult.rank` is a
  positive integer or `nil`; `AuctionImbalance.side` is the venue's string. New helpers in
  `Types.Validate`: `positive!/2`, `text!/2`, `maps!/2`, `dates!/2`, `symbol_lists!/2`.
- **`PollingFeed` reports unrequested symbols in a bulk answer that also carries wanted
  ones.** Only the all-unwanted answer was reported; with one wanted event among them the rest
  were dropped in silence. The wanted events are delivered, then `{:unrequested_symbols, names}`
  is recorded exactly as in the all-unwanted case (`status/1`'s `last_error`, the debug log).

### Changed

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

### Added

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

### Changed (test support)

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

## [0.3.108] - 2026-10-18

### Documentation

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

## [0.3.107] - 2026-10-18

### Added

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

### Changed

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

### Fixed

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

## [0.3.106] - 2026-10-18

### Documentation

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

## [0.3.105] - 2026-10-18

### Fixed

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

## [0.3.104] - 2026-10-18

### Added

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

### Changed

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

## [0.3.103] - 2026-10-18

### Added

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

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

### Fixed

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

## [0.3.102] - 2026-10-17

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

## [0.3.101] - 2026-10-17

### Added

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

## [0.3.100] - 2026-10-17

### Changed (tick AJ)

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

## [0.3.99] - 2026-10-17

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

- **`HttpClient` `:rate_limit_key` (additive).** Selects only the limiter bucket (default:
  `:provider`). A venue that meters order writes apart from reads passed a second `:provider`,
  and Core then echoed it into `{:exchange_error, provider, _}` and into every
  `[:dp_exchange, :request | :rate_limit, _]` event, so the venue identity was wrong. Now
  `:provider` is always the identity; the limiter receives the key and, as
  `:telemetry_provider`, the identity, which `DefaultRateLimiter` uses for its own events.
  Documented in `usage-rules/adapter.md`.
- **A blocking wait on two buckets was counted as two rate-limit hits (tick AI).** With
  `rate_limit_per_endpoint: true` and `rate_limit_blocking: true` the pre-check `check/3` and
  then `acquire/3` each emitted `[:dp_exchange, :rate_limit, :hit]` for the same wait.
  `DefaultRateLimiter.check/3` takes `telemetry: false`; `HttpClient` asks quietly on the
  blocking path and reports a wait beyond the timeout once itself.
- **A `throw` out of the transport left a `[:request, :start]` span open (tick AI).** Only
  raises and exits were caught, so the throw skipped `:exception` and unwound into the calling
  venue process. It now closes the span with `kind: :throw` and returns `{:error, _}` like an
  exit. Telemetry moduledoc now states the `:exception` metadata exactly (`kind`, `status: nil`,
  `result: :exception`), the reconnect measurements and the pairing rule. `HttpClient` docs now
  list `:plug`, `:adapter`, `:req_adapter`, `:headers`, `timeout: :infinity` and
  `retry_attempts: 0`.

## [0.3.98] - 2026-10-16

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

## [0.3.97] - 2026-10-16

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

## [0.3.96] - 2026-10-15

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

## [0.3.95] - 2026-10-15

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

## [0.3.94] - 2026-10-14

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

## [0.3.93] - 2026-10-14

- **Facade consistency (tick AD): one reason for each of ten situations, documented.**
  `DpExchange.Core.Venue` and `DpExchange.Core.Notice` (and `usage-rules.md`) now name:
  `{:refused, :not_listed}` (a well-formed symbol the venue does not list), `{:refused,
  :no_quote}` (listed, nothing to quote), `{:refused, {:account_not_found, id}}`, `{:error,
  {:invalid_credentials, venue}}` (padded or control-character credential, before signing,
  never echoed), `{:error, {:insecure_url, venue}}`, `{:error, {:invalid_range, range}}`,
  `{:error, {:range_unavailable, timeframe, earliest: dt | nil, requested: dt}}` beside
  `:range_too_wide` and `:history_truncated`; a poll route's `:link_down` closed by one
  latched `:link_up`; the notice kind rules (`:data_quality` for gaps and undecodable books,
  `:refusal` with `details.symbols`, `:degraded` only for a slower path, `:credentials_rejected`
  for a poll as well as a stream); and `details.reason` as an atom or tagged tuple with free
  text in `details.message`. **Breaking, for venue packages:** each venue moves its returned
  reasons to these (a changed reason, tracked in that venue's own CHANGELOG); Core itself
  returns none of them.
  - **New optional callback `wanted/1`** (`@optional_callbacks`, so no venue fails
    completeness): the symbols the consumer asked to stream, against `coverage/1`'s observed
    delivery. The contract is now 89 callbacks, 85 required and 4 optional.
  - **Conformance: group 40** (when the fake exports `wanted/1`: a list or `MapSet` holding
    every symbol subscribed and every symbol `coverage/1` reports) and **group 41**, opt-in
    and skipped by default: `unlisted_symbol:` (`get_price/2` is `{:refused, :not_listed}`)
    and `padded_credentials:` (`get_balances/2` is `{:error, {:invalid_credentials,
    runtime_id}}`). No existing venue run changes.
  - `ReferenceVenue` (test support) answers `{:refused, :not_listed}` where it said
    `{:refused, :symbol_not_listed}`, exports `wanted/1` and refuses a padded credential.
    `contract_teeth_test.exs` pinned the old tag and is updated.
  - `CanonicalPair`: the upcased `asset_aliases` map was rebuilt on every `to_canonical/2` and
    `to_exchange/2` call (per message in a venue). It is cached in one `:persistent_term`,
    keyed by the caller's own alias map (so a changed mapping can only miss) and capped at 64
    distinct maps. The quote-suffix sort is unchanged and stays per call, as documented.
  - `FakeInjection`: a symbol's `by_symbol` entry is removed when its last queued outcome is
    popped (it kept `symbol => []` for the life of the agent); queuing no outcomes adds none.

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

## [0.3.92] - 2026-10-14

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

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

## [0.3.91] - 2026-10-13

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

## [0.3.90] - 2026-10-13

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

## [0.3.89] - 2026-10-12

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

## [0.3.88] - 2026-10-12

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

## [0.3.87] - 2026-10-12

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

## [0.3.86] - 2026-10-12

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

## [0.3.85] - 2026-10-12

### Changed

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

### Fixed

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

## [0.3.84] - 2026-10-12

### Fixed

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

### Changed

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

## [0.3.83] - 2026-10-12

### Added

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

## [0.3.82] - 2026-10-12

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

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

### Changed

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

## [0.3.81] - 2026-10-12

### Fixed

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

## [0.3.80] - 2026-10-12

### Fixed

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

## [0.3.79] - 2026-10-12

### Added

- **The family conventions are part of the contract, and four new conformance assertions hold
  a venue's Fake (and, for 32, its facade) to them.** Every venue already follows them; until
  now only prose said so.
  - **32. Streaming arguments are validated first.** On `subscribe/2`, `unsubscribe/2` and
    `update_symbols/2` a malformed symbol list (not a list, improper, a non-string or blank
    element) is `{:error, {:invalid_symbols, value}}`, never `:ok`. On `subscribe/2` and
    `subscribe_notices/1` a `:to` that is not a pid or a non-boolean atom is
    `{:error, {:invalid_subscriber, value}}`; symbols are judged before the subscriber. Run
    against the Fake and against the real facade with `absent_feed_opts` (the no-feed path of
    assertion 28: nothing starts, nothing reaches the network), so bad input answers the
    same whether or not a feed runs.
  - **33. A symbol that is not text is refused before credentials.** `nil`, a number or a
    blank string on `get_price/2` and `get_top_of_book/2` is `{:error, {:invalid_symbol,
    value}}`, asked with credentials stripped.
  - **34. `place_order/3` request discipline.** A non-map is `{:invalid_order_request, r}`, a
    request with no `:order_type` is `{:missing_field, :order_type}`, an unread key is
    `{:unsupported_order_field, key}`, and a `:price` on a `:market` order is
    `{:unexpected_field, :price}` (tried with no duration and with each duration the venue's
    `capabilities/0` names, because the duration a venue takes on a market order is its own).
    Skipped when `place_order/3` is `:unsupported`; the market check is skipped when `:market`
    is not a supported order type.
  - **35. Order ids are checked first.** `nil` or a blank id on `get_order/3` and
    `cancel_order/3` is `{:error, {:invalid_order_id, value}}`, with credentials stripped
    and with and without the venue's `endpoint_opts:`, so neither the account option nor
    credentials may be the answer.
  - A callback a venue declares `:unsupported` is skipped, as elsewhere. Each decision is a
    public function with a teeth test (`refusal_fault/2`, `eligible_cases/3`, `case_faults/2`,
    `streaming_refusal_cases/2`, `single_symbol_refusal_cases/1`, `order_request_cases/1`,
    `market_price_requests/2`, `market_price_fault/1`, `order_id_refusal_cases/1`), run against
    a module that breaks every convention at once.
  - **Expect a venue's CI to go red if its Fake or facade drifts.** A venue whose
    `order_args: %{order_request: ...}` is not a request its Fake accepts must declare one.

- **`Core.Venue` documents the conventions where a venue author reads them.** The callback
  `@doc`s of `get_price/2`, `get_top_of_book/2`, `subscribe/2`, `unsubscribe/2`,
  `update_symbols/2`, `subscribe_notices/1`, `coverage/1`, `place_order/3`, `preview_order/3`,
  `replace_order/4`, `cancel_order/3`, `get_order/3` now state the error shapes, the check
  order (arguments, then feed; request, then account option, then credentials), that
  `subscribe([])` opens nothing, that a cancel is sent once, and that a preview the venue
  rejects is `{:refused, {:preview_rejected, rejects}}`. New types: `invalid_argument/0`,
  `order_request_error/0`, `order_unconfirmed/0`, `write_unconfirmed/0` and
  `order_write_result/1` (the specs of `place_order/3` and `replace_order/4` now use it; the
  accepted answers are unchanged).
  - **Unconfirmed order writes.** An outcome of `place_order/3` or `replace_order/4` that may
    have reached the venue and is not proven is `{:error, {:order_unconfirmed,
    client_order_id_or_nil, reason}}`, never a bare error and never a refusal; a replace that
    was accepted but could not be read back is `{:error, {:replace_accepted_unread,
    order_id, reason}}`.
  - **Non-order writes (documentation only this release; no assertion yet).** `withdraw/5`,
    `transfer_internal/4`, `convert/4`, `commit_conversion/2`, `stake/3`, `unstake/3`,
    `cancel_all_orders/2` and the other state-changing writes document rules W1 to W7:
    `{:error, {:write_unconfirmed, key_or_nil, reason}}` for an outcome that may have reached
    the venue, retries only under an idempotency key the venue honours, a confirming body
    with missing optional fields is `{:ok, struct}`, amounts as Decimal/integer/numeric string
    with `{:invalid_amount, v}` and `{:invalid_transfer, :same_account}`, `Types.*` returns in
    the real path and the Fake, and list reads that refuse an unreadable row or a possibly
    truncated reply (`{:error, :history_truncated}`).

### Changed

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

## [0.3.78] - 2026-10-11

### Fixed

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

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

## [0.3.77] - 2026-10-11

### Fixed

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

- **`[:dp_exchange, :request, :exception]` no longer carries the arguments of the failed
  call.** `:stacktrace` is documented metadata and reaches every handler and exporter, and a
  frame that fails to match (`FunctionClauseError`) records its argument list, which for a
  request call is the headers: the `Authorization` value or the signature. `Core.HttpClient`
  now reduces each frame's argument list to its arity (`{module, function, arity, location}`).
  The `reason` text in that event, in `"Request failed: ..."` and in `"Request exception:
  ..."` is bounded (`limit: 50`, `printable_limit: 2_048`) and byte-identical to `inspect/1`
  for anything inside the bounds.
- **`HttpClient.get/3` sends a params map in key order.** A map of more than 32 keys has no
  iteration order of its own, so a retry could send the same params in a different order, and
  a venue that signs the query string saw two strings. At 32 keys or fewer the order is
  unchanged.
- **`Core.PollingFeed` queues ticks in an Erlang `:queue`,** not a list appended to. With
  thousands of symbols and a fetch slower than the interval the queue holds one job per
  symbol, and `queue ++ [job]` copied all of them on every tick inside the process that
  answers `status/1`. Same FIFO order, same one-fetch-at-a-time concurrency.
- **`Core.PollingFeed`'s "delivered NOTHING" warning and its debug line bound the failure
  reason** (`limit: 20`, `printable_limit: 512`). A fetch's `{:error, reason}` can be a whole
  decoded venue body, and the warning repeats every sweep for as long as an outage lasts. A
  raised exception's message is cut at 2_048 characters. `status/1`'s `last_error` is still
  the term the fetch returned.

### Changed

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

## [0.3.76] - 2026-10-11

### Fixed

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

### Changed

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

## [0.3.75] - 2026-10-11

### Changed

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

## [0.3.74] - 2026-10-11

### Changed

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

## [0.3.73] - 2026-10-11

### Changed

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

### Added

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

### Fixed

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

## [0.3.72] - 2026-10-11

### Changed

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

### Performance

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

## [0.3.71] - 2026-10-11

### Changed

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

### Fixed

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

## [0.3.70] - 2026-10-11

### Changed

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

### Fixed

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

- **`VolumeProfile.point_of_control/1` raised on a price key spelled `"NaN"` or
  `"Infinity"`.** `Decimal.parse/1` reads both in full, so the tie-break handed a non-finite
  `Decimal` to `Decimal.compare/2`, which raises, inside the consumer's process. Such a key is
  now unreadable like any other non-number: it loses the tie to a real price.
- **Conformance assertions 13, 20, 27 and 29 waited 0.5 to 2 seconds for a message** from a
  spawned task or a fake's delivery. Those macros compile into every venue's suite, where a
  loaded CI machine turned a slow spawn into a failure. The margin is now 5 seconds; a
  passing run is not slower. No assertion newly fails a venue.

## [0.3.69] - 2026-10-11

### Added

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

## [0.3.68] - 2026-10-11

### Changed

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

### Fixed

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

## [0.3.67] - 2026-10-11

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

### Added

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

### Changed

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

### Fixed

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

## [0.3.66] - 2026-10-11

### Changed

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

- **`Capabilities.new/1` checks the scalar fields' kind.** `supported_quotes` must be a list
  of non-empty strings (`[:USD]` built the right pair strings and failed every
  `in ["USD"]`), `max_candles_per_request` a positive integer or `nil` (`0`, `"300"` and
  `300.0` were accepted), `measured_at` a `Date` or `nil`, and `measured_against` a non-empty
  string or `nil`. A string date or an empty `measured_against` read as provenance and
  carried none. All five venues declare these correctly.
- **`Fill.new/1`, `Trade.new/1`, `Position.new/1`, `OrderLeg.new/1` refuse a value outside
  the vocabulary their typespec states.** `side` must be `:buy | :sell` (`Trade` also `nil`)
  or `:long | :short` for a `Position`; `Fill.liquidity` `:maker | :taker | nil`;
  `OrderLeg.ratio` a positive integer and `position_effect` `:open | :close | nil`;
  `Trade.broken` a boolean. `Position` built `:buy` was read by a consumer matching
  `:long | :short` as no side or the wrong one. Venues build these as struct literals, so
  none is affected. `Validate.one_of!/3` and `Validate.positive_integer!/2` are new and public.
- **`ApprovedAddress.new/1` refuses a `status` other than `:pending | :active | :rejected`,**
  and `ApprovedAddress.usable?/2` answers `nil` ("unknown, not usable") for a struct literal
  carrying any other, where it raised `FunctionClauseError` in the caller's process.
- **`PollingFeed` refuses an unusable duration at start.** `interval_ms` and
  `fetch_timeout_ms` must be positive integers and `start_delay_ms` a non-negative one;
  anything else stops the feed with `{:invalid_option, key, value}`. `interval_ms: 0`
  re-polled the venue as fast as the fetch returned, and a negative or non-integer value
  raised inside `init/1` naming `send_after/3` rather than the option.
- **`Fanout.deliver/4` raises on a `:max_queue_len` that is present but not a positive
  integer.** `queue_len >= "10000"` is false for every queue, so a string or `:infinity`
  switched back-pressure off exactly as `nil` once did (`nil` still means the default).
- **`HttpClient.build_auth_headers/5` raises `ArgumentError` for a missing, `nil` or empty
  credential under `:bearer`, `:basic` and `:hmac_sha256`.** `"#{nil}"` is `""`, so an unset
  config value built `Authorization: Bearer ` and the request went out looking signed.
- **`DpExchange.venue/1` refuses a name containing `.` or `/`.** `Macro.camelize/1` turns `/`
  into `.`, so `"schwab/capabilities"` resolved to any module nested under a venue that
  exports `capabilities/0`.
- **`CanonicalPair.to_exchange/2` returns a canonical symbol with no base (`"-USD"`)
  uppercased and untouched,** where it returned the bare quote `"USD"` on a concat venue.
- **`Core.AdapterContract` group 1 fails when `fake:` or `sample_pairs:` is missing.** Groups
  14, 20, 23 and 25 skip on an empty `sample_pairs:` and 29 loops zero times, so a venue
  configured without them passed the suite having checked almost none of it. All five venues
  and the reference venue pass both.
- **`CredentialRedactionCheck` recognises more secret field names:** `x_api_key`,
  `signing_key`, `cookie`, `session_token`, `id_token`, `jwt`, `secret_key`,
  `api_passphrase`, `api_token`, `auth_token`, `bearer_token`, `private_key_pem`. A struct
  with `defstruct [:secret_key]` printed in cleartext and passed. `credentials` is left out
  on purpose: it normally holds a struct that is itself redacted.

### Fixed

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

## [0.3.65] - 2026-10-11

### Added

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

## [0.3.64] - 2026-10-10

### Changed

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

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

### Fixed

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

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

## [0.3.63] - 2026-10-10

### Fixed

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

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

## [0.3.62] - 2026-10-10

### Fixed

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

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

## [0.3.61] - 2026-10-10

### Fixed

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

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

## [0.3.60] - 2026-10-10

### Added

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

### Changed

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

## [0.3.59] - 2026-10-10

### Changed

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

## [0.3.58] - 2026-10-10

### Fixed

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

## [0.3.57] - 2026-10-10

### Fixed

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

### Documentation

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

## [0.3.56] - 2026-10-10

### Fixed

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

## [0.3.55] - 2026-10-10

### Fixed

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

## [0.3.54] - 2026-10-10

### Fixed

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

### Documentation

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

## [0.3.53] - 2026-10-05

### Fixed

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

## [0.3.52] - 2026-10-03

### Fixed

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

## [0.3.51] - 2026-10-02

### Fixed

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

### Added

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

## [0.3.50] - 2026-10-02

### Added

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

## [0.3.49] - 2026-10-01

### Fixed

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

## [0.3.48] - 2026-09-29

### Fixed

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

## [0.3.47] - 2026-09-29

### Fixed

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

## [0.3.46] - 2026-09-28

### Added

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

## [0.3.45] - 2026-09-28

### Added

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

## [0.3.44] - 2026-09-28

### Fixed

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

## [0.3.43] - 2026-09-28

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

## [0.3.42] - 2026-09-28

### Fixed

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

## [0.3.41] - 2026-09-28

### Changed

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

## [0.3.40] - 2026-09-27

### Added

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

## [0.3.39] - 2026-09-27

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

## [0.3.38] - 2026-09-27

### Fixed

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

## [0.3.37] - 2026-09-27

### Fixed

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

## [0.3.36] - 2026-09-27

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

## [0.3.35] - 2026-09-25

### Documentation

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

## [0.3.34] - 2026-09-25

### Documentation

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

## [0.3.33] - 2026-09-24

### Added

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

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

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

### Fixed

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

## [0.3.32] - 2026-09-23

### Fixed

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

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

## [0.3.31] - 2026-09-23

### Added

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

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

### Fixed

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

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

## [0.3.30] - 2026-09-23

### Fixed

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

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

  | | before | after 3 remove/re-add cycles |
  | --- | --- | --- |
  | churned symbol | 5 fetches/s | **20 fetches/s (x4)** |
  | control symbol | 5 fetches/s | 5 fetches/s |

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

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

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

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

## [0.3.29] - 2026-09-15

### Added

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

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

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

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

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

### Fixed

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

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

## [0.3.28] - 2026-09-15

### Fixed

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

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

  | | published 0.3.27 | this change |
  | --- | --- | --- |
  | `dp_exchange_coinbase` | **48 tests, 0 failures** | 48 tests, 3 failures |
  | all five venues | — | 3 failures each |

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

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

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

## [0.3.27] - 2026-09-15

### Fixed

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

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

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

  | break | published 0.3.26 | this change |
  | --- | --- | --- |
  | `coverage/1` reports a route that is a claim (`:will_stream`) | **48 tests, 0 failures** | 48 tests, 1 failure |
  | `coverage_by_kind/1` names a symbol `coverage/1` does not | **48 tests, 0 failures** | 48 tests, 1 failure |

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

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

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

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

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

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

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

## [0.3.26] - 2026-09-15

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

## [0.3.25] - 2026-09-15

### Fixed

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

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

  | `dp_exchange_gemini` fake | contract suite |
  | --- | --- |
  | one level a side, as shipped | **48 tests, 0 failures** — and nothing can change that |
  | a second, deliberately misordered level | 48 tests, 1 failure |

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

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

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

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

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

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

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

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

## [0.3.24] - 2026-09-14

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

## [0.3.23] - 2026-09-14

### Fixed

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

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

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

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

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

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

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

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

## [0.3.22] - 2026-09-14

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

## [0.3.21] - 2026-09-14

### Changed

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

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

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

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

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

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

## [0.3.20] - 2026-09-14

### Fixed

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

## [0.3.19] - 2026-09-14

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

## [0.3.18] - 2026-09-14

### Fixed

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

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

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

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

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

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

## [0.3.17] - 2026-09-14

### Added

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

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

### Fixed

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

## [0.3.16] - 2026-09-14

### Added

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

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

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

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

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

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

## [0.3.15] - 2026-09-13

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

## [0.3.14] - 2026-09-13

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

## [0.3.13] - 2026-09-13

### Added

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

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

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

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

## [0.3.12] - 2026-09-13

### Fixed

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

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

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

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

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

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

## [0.3.11] - 2026-09-12

### Changed

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

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

## [0.3.10] - 2026-09-12

### Fixed

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

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

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

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

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

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

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

### Changed

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

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

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

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

## [0.3.9] - 2026-09-12

### Fixed

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

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

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

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

### Added

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

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

## [0.3.8] - 2026-09-12

### Fixed

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

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

### Changed

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

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

## [0.3.7] - 2026-09-12

### Added

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

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

### Fixed

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

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

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

## [0.3.6] - 2026-09-11

### Fixed

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

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

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

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

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

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

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

## [0.3.5] - 2026-09-11

### Fixed

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

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

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

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

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

## [0.3.4] - 2026-09-11

### Fixed

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

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

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

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

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

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

## [0.3.3] - 2026-09-11

### Fixed

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

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

  | dead pids in set | µs per fan-out |
  |---|---|
  | 0 | 0.095 |
  | 50 | 0.956 |
  | 200 | 4.301 |
  | 1000 | 22.842 |

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

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

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

## [0.3.2] - 2026-09-11

### Fixed

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

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

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

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

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

### Changed

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

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

## [0.3.1] - 2026-09-11

### Removed — BREAKING

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

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

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

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

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

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

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

### Added

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

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

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

### Changed

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

## [0.2.8] - 2026-09-11

### Added

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

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

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

## [0.2.7] - 2026-09-11

### Fixed

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

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

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

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

### Added

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

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

### Changed

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

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

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

## [0.2.6] - 2026-09-10

### Added

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

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

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

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

### Changed

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

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

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

## [0.2.5] - 2026-09-10

### Documentation

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

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

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

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

## [0.2.4] - 2026-09-10

### Documentation

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

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

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

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

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

## [0.2.3] - 2026-09-10

### Fixed

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

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

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

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

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

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

## [0.2.2] and earlier - 2026-09-10

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

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

### Added

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

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

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

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

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

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

### Removed — BREAKING

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

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

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

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

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

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

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

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


### Documentation

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

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

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

### Documentation

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

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

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

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

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

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

### Documentation

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

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

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

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

### Documentation

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

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

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

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

### Documentation

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

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

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

### Fixed

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

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

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

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

### Added

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

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

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

### Changed

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

### Documentation

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

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

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

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

### Fixed

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

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

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

### Added

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Changed

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Fixed

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Added

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### Fixed

- **Six false claims in shipped documentation, corrected against the code.** Nothing tests
  prose, and all six were the same shape: a statement about the family that was true when it
  was written and rotted silently.
  - `usage-rules/testing.md` and `docs/guides/building-an-exchange-package.md` both said the
    conformance suite has **fifteen** assertion groups. It has had **sixteen** since
    assertion 16 ("internal wiring") landed, as `assertions/0` and `AdapterContract`'s own
    moduledoc already said. The identical drift is recorded once before, at fourteen.
  - `usage-rules/feeds.md`'s per-venue table had three of five `streamable` rows wrong:
    Coinbase is `[:quotes, :order_book]` (not `[:quotes]`), Webull is
    `[:quotes, :top_of_book, :trades]` (not `[:quotes]`), and Robinhood is `[:top_of_book]`
    — deliberately **not** `[:quotes]`, because that venue publishes no last-trade data to
    poll for.
  - `usage-rules/money-movement.md` showed Coinbase as a blank row. `transfer_internal/4`
    is live there, and so are `list_payment_methods/2` and `get_payment_method/3`; only
    withdrawal and everything around it is `:unsupported`. "Gemini is the only venue that
    moves money through its API" is now stated as what is actually true — the only one whose
    API moves funds **off** the venue.
  - `Capabilities`' `supports_order_preview` comment said only Schwab declares it. Coinbase
    and Webull declare it too.
  - `docs/guides/building-an-exchange-package.md` said no venue checked so far has a working
    sandbox. Gemini's does, and `usage-rules/environments.md` has said so, measured, since
    2026-08-28.
  - `docs/reference/core/negative-claims.md` said Core makes no claim about what a venue
    serves. Four of its shipped tables do exactly that, unchecked by any test; the audit
    section now names them as the place a venue fact goes wrong in Core.

- **The `nil`-vs-absent `Keyword.get` trap, closed as a class rather than one incident at a
  time (C1).** `polling_feed.ex`'s `:start_delay_ms` already carried a fix and an incident
  comment; the same trap was open at every other default-bearing option in `PollingFeed`,
  `HttpClient` and `DefaultRateLimiter` — reachable because every venue forwards its own
  `opts` unchanged by family convention, so a key the caller never set arrives as `key: nil`
  rather than absent, and `Keyword.get(opts, key, default)` only substitutes `default` for
  an ABSENT key. `interval_ms: nil` crashed `Process.send_after/3` and restarted the feed
  straight into the same crash; `on_refusal: nil` raised `BadFunctionError`; `symbols: nil`
  raised inside `MapSet.new/1`. **`HttpClient`'s `retry_attempts: nil` was worst**: Erlang
  term ordering sorts `nil` above every integer, so `nil > 1` is `true`, and a forwarded
  `nil` silently entered the retry branch and died computing `4 - nil` — an `ArithmeticError`
  raised directly in the *calling* venue process, which this library does not supervise.
  Fixed with one shared helper, `DpExchange.Core.Config.opt/3`, applied at every reachable
  site across the three modules (not only the four originally named) — a present-and-`nil`
  value is now treated the same as an absent one everywhere a default applies, and an
  explicit `false` is still honoured, because `opt/3` deliberately does not use `||`.

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

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

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

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

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

### Added

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

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

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

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

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

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

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

### Fixed

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

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

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

### Documentation

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

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

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

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

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

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

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

### Added

- **`place_orders/3`** — several orders in one request, which closes OQ8.

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

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

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

### Changed

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

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

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

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

### Added

- **`place_orders/3`** — several orders in one request, which closes OQ8.

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

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

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

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

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

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

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

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

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

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

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

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


- **`list_networks/2` and `list_fee_promos/1`.**

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

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

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


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

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

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


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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

### Changed





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

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

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

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

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

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

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

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

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

- **`Types.Order` gains `:time_in_force` and `:legs`.** `Capabilities.supported_time_in_force`
  declared what a venue accepts while the order type had no field for it, so a caller
  reading an order back could not tell an IOC that expired from a GTC still working.
- **Derivatives.** `Types.Funding` (settled `:amount` kept apart from `:estimated_amount` —
  a real response has them 40% apart) and `Types.ContractStats` (mark and index are separate
  prices, and neither is a traded price), with `get_funding/2` and `get_contract_stats/2`.
- **Conversions.** `Types.Conversion` plus `quote_conversion/4`, `commit_conversion/2` and
  `get_conversion/2` — the facade's only two-step write. `:expires_at` is the point:
  committing an expired quote can fill at the *current* rate, which looks like success.
  `expired?/2` returns `nil` when no expiry was stated — unknown, not valid.
- **Portfolios.** `Types.Portfolio` and `list_portfolios/1`. A portfolio is an **address**,
  not a value; balances, orders and positions are addressed with `portfolio: id` in `opts`
  rather than by adding a parameter to forty signatures.
- **Money movement, write side.** `Types.DepositAddress`, `Types.ApprovedAddress`,
  `Types.Withdrawal`, and `get_deposit_address/3`, `list_approved_addresses/1`,
  `estimate_withdrawal_fee/4`, `withdraw/5`.

  **`withdraw/5` is the only operation in this contract that cannot be undone.** The
  allow-list is first-class: `ApprovedAddress.usable?/2` returns `nil` for a pending address
  with no stated activation, because venues delay first use precisely so a stolen account
  cannot add an address and drain it. `DepositAddress.memo_required` is **tri-state** — a
  deposit missing a required memo is credited to nobody, so `nil` must never be defaulted to
  `false`. `:network` is enforced on both.
- **`Core.Types.Position`** and **`get_positions/1`** — exposure, distinct from a balance and
  not derivable from one. `:side` is explicit and `:quantity` always positive, because
  venues disagree about how to say "short" and a guessed sign convention yields a position
  that is exactly backwards while every number stays plausible. Realised and unrealised P&L
  are separate and never summed. **`:liquidation_price` of `nil` means the venue did not
  say, not that the position is safe.**
- **`data_kind` gains `:top_of_book`, `:candles` and `:positions`.** Measured against
  Gemini's AsyncAPI and Schwab's Streamer service list: all three are streamed by a venue in
  the family and had no kind. `:top_of_book` is deliberately not `:order_book` — venues
  stream them on separate channels because one carries a level and the other a book.
  `t:data_kind/0` records the full channel-to-kind mapping so it can be checked rather than
  trusted.
- **Custodial staking.** Six callbacks — `get_staking_rates/1`, `get_staking_balances/1`,
  `get_staking_rewards/1`, `get_staking_history/1`, `stake/3`, `unstake/3` — and a
  `has_staking` capability flag, which earlier notes recorded as shipped and which did not
  exist.

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

  Four types, shaped by the venues' published schemas:
  - `Types.StakingBalance` — keeps `staked`, `available_to_trade` and
    `available_for_withdrawal` **apart**; a real response has the whole position redeemable
    and none of it tradable. `by_provider` is carried, not summed: a redemption is addressed
    to a provider.
  - `Types.StakingRate` — percentages only, `rate_pct` and `apy_pct` both named. One venue
    publishes basis points, a simple percentage and an APY for the same position;
    `bps_to_pct/1` lives here so the 100× conversion is done once.
  - `Types.StakingReward` — carries its accrual period and the rate *at accrual*.
  - `Types.StakingTransaction` — carries the unbonding progression `amount` /
    `amount_paid_so_far` / `amount_remaining`. **`settled?/1` returns `nil` when the venue
    reports no progress** — unknown, not complete.
- **`Core.Types.TopOfBook`** — best bid and ask, with **no `price` field**. `bid_size` and
  `ask_size` are optional (`nil` means *not published*, never zero); `venue_time` is the
  venue's own or `nil`, since several BBO endpoints publish none; `observed_at` is required.
  `mid/1`, `spread/1` and `crossed?/1` are functions, not fields — a mid is derived, and a
  caller has to ask for it rather than find it sitting there looking like venue data.
- **`get_top_of_book/2`** on the `Venue` behaviour, registered in `peripheral_endpoints/0`.
- **Conformance assertion 14, "top of book is not a price"** — asserts the returned struct
  is a `TopOfBook`, that `observed_at` is set, that `venue_time` is the venue's or `nil`,
  and that `TopOfBook` has no `price` field and cannot grow one.

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

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

### Added
- **Five capability fields and two facade callbacks**, closing every contract gap Schwab
  found. Each existed because a venue could not say something true about itself.
  - `ceiling` gained an optional **`:scope`** (`:credential | :account | :application`),
    and `:limit` became `non_neg_integer`. Both matter: a limiter keyed by credential
    **silently over-permits** a venue that counts per account, and a registration granted
    zero throughput is legal and is **not** `:unsupported` — the endpoint exists and the
    venue serves it; that application cannot use it, and the remedies differ.
  - **`supported_sessions`** — which trading session an order may name. `[]` is the
    continuous-market case and stays the default. `[:regular]` alone **raises**: it says
    nothing, and a consumer would build a session selector with one option.
  - **`supports_order_preview`**, **`supports_order_replace`**, **`supports_multi_leg_orders`**
    — all raise if claimed while `place_order/3` is `:unsupported`.
  - **`catalog_access`** (`:enumerable | :query_only`) — whether the catalogue can be
    listed at all. `:query_only` raises if `get_symbols/1` is `:unsupported`, because
    "searchable only" and "not served at all" are different facts.
  - **`preview_order/3`** and **`replace_order/4`** as **required** facade callbacks.
    Required rather than optional: the facade is one fixed set, and optionality is for
    ceremony. Both are peripheral, and `replace_order/4`'s reason states the risk —
    absence means cancel-then-place, which works and opens a window with no order live.
- **Four order types**: `:trailing_stop`, `:trailing_stop_limit`, `:market_on_close`,
  `:limit_on_close`. Real types Schwab accepts that Core had no word for, so a venue
  serving them had to under-declare — the safe direction, and still a lie.
- **Eight instrument types**: `:option`, `:future`, `:future_option`, `:index`,
  `:mutual_fund`, `:bond`, `:forex`, `:cash_equivalent`. `[:spot, :perp]` was the whole
  vocabulary while every venue was crypto; an option is not a spot instrument, so an
  equities broker declared `[:spot]` plus a comment saying that understated it. **A
  declaration that needs a comment to be true is what this struct exists to prevent.**
- Two conformance assertions: the order-shape claims must match what the facade answers,
  and `catalog_access` must match how `get_symbols/1` behaves without a query.

### Documentation

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

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

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

## [0.1.11] - 2026-08-31

### Fixed

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

## [0.1.10] - 2026-08-31

### Added

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

### Fixed

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

## [0.1.9] - 2026-08-28

### Fixed

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

### Added

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

