D94 — Concurrent multi-provider routing: the ProviderRouter substrate (RESOLVED, supersedes D60)
Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. Program context + the per-capability build plan live in
docs/tracking/multi-provider/.
D60 held that external providers are replaceable, not concurrent — one live provider at a time,
swappable by config. That is now superseded. The platform routes per (capability group × tenant jurisdiction): different capability groups (and the same group in different jurisdictions)
may resolve to different providers concurrently. This is the VA0 substrate the whole multi-provider
program (onboarding, fraud, monitoring, AML, travel rule, custody, conversion, fiat rails, cards)
builds on; it ships additive and behaviour-preserving — exactly one live provider today
(conduit), so every capability still resolves to it until a grant says otherwise.
The seam. A CapabilityGroup enum + a RequiresProvider bus marker + a ProviderGuard contract
live in Foundation (the shared kernel — no new module edge). ProviderRouter +
BusProviderGuard + the per-tenant override store live in features (the routing analogue of the
entitlement resolver); the central grants table lives in jurisdictions. rails owns only a
provider-key → adapter map. Crucially, ProviderRouter::resolve() returns a provider-key STRING,
never a rails type — ModuleBoundaryTest forbids features/jurisdictions depending on rails,
so the router names providers by key and rails turns the key into an adapter. Resolution is
compliance-first and fail-closed (a miss is a hard ProviderNotRoutableException, never a silent
fallback to a non-licensed provider): the tenant's jurisdiction must be active (the existing
JurisdictionGate, D75); a central jurisdiction_provider_grants row must license the
(jurisdiction, capability group); a live per-tenant tenant_provider_overrides row (RLS-isolated)
may pin a provider only among the jurisdiction's permitted keys (an override can never route to a
non-licensed provider — compliance beats commerce); otherwise the grant marked is_primary (a
partial-unique index guarantees exactly one primary per group); and where a grant declares a
data_region, data residency is an enforced precondition — the region must match the
jurisdiction's and a dpa_ref must be recorded, else refuse. features:sync populates the central
grants from a JurisdictionProviderRegistry (unknown jurisdiction → loud failure). The bus dispatch
guard sits beside the RequiresFeature guard and audits command.provider_denied with a
machine-readable reason on every refusal.
Scope of the substrate increment (VA0). Deferred, not dropped: the richer ProviderResolution
(provider key + external id via per-customer customer_provider_bindings stickiness) — the router
returns a key-only string for now; per-capability router-driven binding of the 13 rails capability
interfaces (they still default to conduit); wiring RequiresProvider onto real domain commands
(each lands with its capability); and the wholesale removal of the Conduit vocabulary (decision 5 of
the program — a later, dedicated increment, kept additive here so gates stay green). Registry-side
withdrawal pruning (a de-listed grant is not auto-soft-deleted) is a known limitation shared with
the feature-grant sync — the fail-closed guarantee is about resolution time, not registry drift.