Skip to main content

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).


← Decision log index