D92 — Defined pair-rate providers: the named treasury.rate_providers registry behind the ExchangeRateProvider contract (RESOLVED, extends D49)
Architecture decision record, relocated verbatim from the retired single-file
docs/tenancy/decision log. Status, thematic clusters, and how to record a new ADR: the decision log index.
Rate provisioning was formal at the CONTRACT level (the ExchangeRateProvider seam + the caching
freshness decorator) but the provider SET was implicit — one hardwired binding in
TreasuryServiceProvider. It is now explicit + configurable: treasury.rate_providers is a
NAMED registry (name => class-string, the config/rails.php providers idiom), and
rate_provider / rate_provider_fallback select the primary and optional fallback source by
name. Services\ExchangeRateProviderFactory resolves the selection and ALWAYS wraps it in
CachingExchangeRateProvider — the registry chooses the SOURCE, never the freshness mechanism (the
≤20s TTL + sync-refetch guarantee is not opt-outable). Misconfiguration fails LOUD at container
resolution (unknown name / non-contract class → InvalidArgumentException naming the registered
providers), never a silent substitute rate source.
V1 ships one entry ('configured' => ConfiguredExchangeRateProvider) and no fallback; the documented
HttpMarketDataExchangeRateProvider (vendor still an open item, D49) lands as a new registry entry +
a one-line rate_provider switch — config only, no binding change. NO real external rate API is
wired in this phase (fake-provider tests only — ExchangeRateProviderFactoryTest). Per-pair /
per-asset-class provider ROUTING (different named providers for crypto vs fiat pairs) was considered
and deferred: with one real source it is speculative structure; the registry is the seam it would
grow on (tracked in docs/tracking/conduit-treasury-deferrals.md).
Placeholder-grade rates are runtime-flagged, and production-guarded (review follow-up). The
placeholder grade of the V1 rates is not just a docs caveat: ConfiguredExchangeRateProvider carries
the Contracts\PlaceholderRateSource MARKER interface, and the master-balance response surfaces it
as rates_provisional: bool (true whenever the resolved PRIMARY source carries the marker — read
through CachingExchangeRateProvider::usesPlaceholderSource(), since the decorator never changes the
rate's grade) so the FE renders "estimated/provisional" chrome. Marker-driven, deliberately NOT a
rate_provider === 'configured' config-name check: a renamed registry entry or a second
placeholder-grade provider stays correctly flagged, and the real
HttpMarketDataExchangeRateProvider flips the flag off by simply not carrying the marker.
Additionally, ExchangeRateProviderFactory logs a LOUD warning
(treasury.rates.placeholder_rate_source_in_production, audit channel) when a placeholder-grade
primary is resolved in the production environment — once per request cycle (the contract binding
is a singleton). The considered alternative — HARD-FAILING the master-balance endpoint on placeholder
rates in production — was rejected as a product call: breaking every read over a rate-grade
misconfiguration is worse than serving a response that is already explicitly flagged provisional
(recorded with the deferral in docs/tracking/conduit-treasury-deferrals.md).