Skip to main content

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


← Decision log index