Skip to main content

D118 — Bridge rails adapter: sealed PARTIAL adapter over four groups, registered but NOT granted; supersedes D95/D98 primaries

Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. Design of record: docs/superpowers/specs/2026-09-03-bridge-rails-adapter-design.md. The mechanics live in app-modules/rails/src/Bridge/*, app-modules/rails/config/rails.php, and the bridge source in app-modules/webhooks/config/webhook-client.php.

Context

Conduit is no longer the target vendor (decided 2026-09-02 — see the provider-landscape wiki and packages/stables/bridge-sdk/README.md); Bridge (+ Sovera) are the forward vendors. bridge-sdk (ADR D114's first adopter of the OpenAPI-snapshot drift-gate pattern) has shipped as a pure-transport Saloon SDK since before this increment, but with zero app-modules/* consumers — no RailsProvider adapter existed for Bridge at all. D117 (onboarding activity declaration) had to work around that gap with a config-listed, stopgap provider-support matrix precisely because "no RailsProvider adapter exists for Bridge yet" (D117 Deferred). This increment closes that gap on the adapter side: it makes Bridge a first-class, registered rails provider — sealed adapter + webhook ingress — so the platform can run onboarding, fiat rails, and conversion through Bridge. It does not turn any traffic on: committing Bridge to a jurisdiction is a separate, human-approved one-line grant flip (the D95/D98 precedent), because that step touches money paths and compliance routing. Conduit is not ripped out (it remains the baseline grant everywhere), so behaviour is byte-for-byte unchanged on deploy.

Mid-flight scope change (2026-09-03, same day, before merge): Bridge becomes the intended single-vendor shop, including custody. The initial design of record scoped Bridge to Onboarding

  • FiatRails + Conversion and explicitly left Custody to Utila (D98) and left Sumsub (D95) as the intended Onboarding primary. The owner decision below revises the target primary state to Bridge across all four groups Bridge can serve — this ADR records both what ships in this increment (registered, ungranted, exactly as originally scoped) and the revised intended go-live target (Decision items 6–7, Go-live).

Decision

A sealed, PARTIAL BridgeRailsProvider adapter is built and registered over four capability groups — Onboarding, FiatRails, Conversion, Custody — following the Sumsub\SumsubRailsProvider / Utila\UtilaRailsProvider precedent. It is registered in Support\ProviderAdapterRegistry and config('rails.providers') but NOT granted in any jurisdiction_provider_grants row — conduit alone continues to serve every jurisdiction on deploy; Utila and Sumsub remain registered and granted exactly as D98/D95 left them (this increment changes no grant).

  1. ISP scope — what Bridge implements, per the design's capability table. BridgeRailsProvider implements ProviderAdapter + OnboardsCustomers, ManagesCustomers, VerifiesIdentity, FetchesVerification, VerifiesBusinesses (Onboarding, 5); ManagesVirtualAccounts, ManagesPayouts, ManagesWhitelistRecipients, ManagesTransactions (FiatRails, 4); ManagesOrders (Conversion, 1); ManagesWallets, ManagesCustodyTransactions, ManagesWalletSigners, ManagesSigningQuorum, ManagesRegisteredAddresses (Custody, 5 — the last three advertised but stub-only end to end, see item 1a) — fifteen capability interfaces across four routing groups (sixteen entries in the class's implements clause counting the ProviderAdapter marker; see the rails README's implemented-vs-stubbed table for the per-method split), the first multi-group PARTIAL adapter in the seam (Sumsub and Utila each serve exactly one group). Not implemented (routing there fails loud with AdapterMissingCapabilityException, 500): ManagesDocuments (Bridge's hosted KYC/KYB flow collects documents itself — the Sumsub precedent), AssessesFraudRisk (Bridge has no fraud/device-risk capability — Fraud stays Fingerprint-primary/SEON-permitted, D102, untouched by this ADR). Within the fifteen implemented interfaces, methods Bridge structurally cannot serve are stubbed with ProviderOperationNotSupportedException (422, terminal — the Utila precedent), not omitted from the interface: submitApplication for any ApplicationType other than CustomerOnboarding/VirtualAccount; initiateVerification for any VerificationType other than IndividualKyc/BusinessKyb; submitSenderInformation (Bridge has no inbound-deposit RFI); orderRequirements (no requirements-read surface); every method of ManagesWalletSigners, ManagesSigningQuorum, and ManagesRegisteredAddresses (Bridge has no signer/quorum/address-book model — see item 1a); rotateWallet and voteOnTransaction (see item 1a).

1a. Custody scope — advertised-but-stubbed, mirroring the Utila 2.2c precedent, but for a different reason. BridgeRailsProvider advertises ManagesWallets and ManagesCustodyTransactions (so routing binds cleanly if Custody is ever granted to it) but serves a narrower surface than Utila's Console-managed-policy-engine gap: getWallet/ listWallets/createWallet map onto Bridge's custodial wallets (POST /v0/customers/{id}/wallets {chain}); rotateWallet is stubbed (no rotate/replace-key operation exists). initiateTransaction maps onto the same POST /v0/transfers object every other money-movement capability uses, with source.bridge_wallet_id naming the custodial wallet; getCustodyTransaction/ listCustodyTransactions (renamed — see item 1b) read the same transfer resource, scoped to a customer. voteOnTransaction is stubbed (ProviderOperationNotSupportedException) — Bridge has no API-vote / approver model at all (unlike Utila's native vote surface), so the in-app custody-controls approval-quorum engine (the accounts module's N-of-M consensus, D97) is the only N-eyes gate a Bridge-custodied transaction clears; there is no vendor-side co-sign to layer under or over it. ManagesWalletSigners, ManagesSigningQuorum, and ManagesRegisteredAddresses are stubbed in full — Bridge has no signer/quorum concept (custodial wallets are single-key, platform-controlled) and no address-book/allow-list concept (crypto destinations are supplied inline as to_address on each transfer, not pre-registered). Bridge custodial wallets require Bridge Legal & Compliance approval and carry geo exclusions per vendor docs (https://apidocs.bridge.xyz/platform/wallets/overview.md, docs as of 2026-09-03) — an operational precondition distinct from the routing grant itself (see Go-live).

1b. A latent ISP defect, found by building the first multi-group adapter — ManagesCustodyTransactions is renamed getCustodyTransaction/listCustodyTransactions. ManagesTransactions (FiatRails) and ManagesCustodyTransactions (Custody) both declared a getTransaction method (taking ExternalRef) and a listTransactions method — identical method names, incompatible signatures (different filter/DTO shapes). No single-group adapter ever collided on this (Conduit implements only ManagesTransactions; Utila implements only ManagesCustodyTransactions), so the defect stayed latent until BridgeRailsProvider tried to implement both interfaces on one class — a PHP fatal (incompatible redeclaration), not a routing or test failure. Decision: rename the custody pair to getCustodyTransaction(ExternalRef): CustodyTransaction and listCustodyTransactions(...): CustodyTransactionCollection on ManagesCustodyTransactions only — ManagesTransactions::getTransaction/listTransactions are unchanged. A mechanical Serena-driven rename (rename_symbol) sweeps every implementer and caller: Utila\UtilaRailsProvider, the custody-controls consumer(s), accounts, and their tests. This keeps the seam's "one provider key → one adapter class" registry model intact — the alternative of per-group adapter classes (one class per CapabilityGroup, keyed on (provider, group) instead of provider) would have meant a bigger ProviderAdapterRegistry redesign for one naming collision — see Alternatives rejected. The general rule this establishes for the seam, now recorded in the multi-provider-development skill: segregated capability interfaces must use group-distinct method names, even when two groups happen to model a structurally similar read (a transaction lookup), so that a future multi-group adapter can compose both without a rename fire-drill of its own.

  1. Onboarding rides Bridge's KYC-Links path, not the Customers API. submitApplication posts to kyc_links()->create (full_name, email, type, endorsements[], redirect_uri) rather than the full Customers-API KYB payload — the same hosted-flow shape Sumsub uses, and the one D117's pinned LegalStructure/EmploymentStatus/IndividualAccountPurpose/BusinessActivity enums were deliberately kept ready for. documents on the submit payload are not forwarded — Bridge's hosted flow (Persona-backed) collects them directly, mirroring the Sumsub WebSDK precedent for ManagesDocuments. Mapping D117's activity declaration into the fuller Customers-API payload is deferred (see Alternatives rejected + Follow-ups).
  2. A Bridge transfer is the one neutral money-movement object. createPayout, createOrder, and getTransaction/listTransactions all funnel through Bridge's single POST /v0/transfers object — the adapter's Mappers\TransferPayloadMapper maps our three separate neutral payload shapes (payout, order, transaction read) onto it and Mappers\TransferMapper maps the one response object back onto Order/Payout/Transaction depending on call site. Every transfer requires an explicit source (payment_rail, currency, and one of from_address/external_account_id/bridge_wallet_id) — Bridge has no implicit "your default wallet" behaviour the way Conduit does. A payload missing source throws ProviderValidationException('Bridge requires an explicit transfer source') rather than guessing; today's PayoutSubmitter/order makers don't yet supply one (a domain follow-up, not part of this increment — see Follow-ups). BridgeStatusMap maps Bridge's TransferState exhaustively onto the same neutral order/payout/transaction status vocabulary the Conduit mappers already emit — a deliberate choice not to invent a third status vocabulary.
  3. Idempotency keys are normalised to Bridge's ≤64-character requirement. Support\BridgeIdempotencyKey::normalize() returns a caller-supplied key unchanged when it is already ≤64 characters, else SHA-256-hashes it to a fixed 64-hex-character string — the IdempotencyKey value object elsewhere in the seam carries no length ceiling, so Bridge is the first provider requiring this normalisation step. A derive() helper builds a deterministic key (sha256('bridge:'.$method.':'.$parts)) for adapter-internal POSTs the caller supplies no key for (e.g. revokeWhitelistRecipient's underlying deactivate call).
  4. Webhook ingress is asymmetric-signed and registered, with no domain handlers yet. BridgeSignatureValidator verifies Bridge's X-Webhook-Signature: t=<ms>,v0=<base64> header — RSA PKCS#1 v1.5 + SHA-256 over the raw string "<t>.<rawBody>", against one or more comma-separated PEM public keys (rotation, the Sumsub precedent), fail-closed on any malformed input. Deviation from Bridge's own suggested behaviour: a stale timestamp (|now_ms - t| > tolerance_ms, default 600000/10min) is rejected as an invalid signature (rendered 401), not Bridge's suggested 400 — the app's existing invalid-signature render callback in bootstrap/app.php only knows 401, and Bridge's own docs state it retries any non-2xx with exponential backoff regardless of status code, so the deviation has no observable effect on delivery. BridgeTenantResolver resolves the tenant from event_object.client_reference_id first, falling back to a provider_customers lookup on (provider='bridge', external_customer_id) derived from event_object.customer_id ?? event_object.on_behalf_of. Dedup key is Bridge's event_id. No Modules\Rails\Bridge\* import exists inside the webhooks module (D52) — the webhook source is registered (route, validator, resolver, config) with zero domain handlers wired to it; a Bridge webhook is signature-verified, deduped, stored, and acked, but produces no mirror write yet (see Follow-ups).
  5. Registered but NOT granted — the go-live grant is a separate, human-approved step. FinanceProvider::Bridge, the bridge config('rails.providers') entry, and ProviderAdapterRegistry::register('bridge', BridgeRailsProvider::class) all ship in this increment — Bridge becomes vendor-logged, translatable, and routable in principle — but no jurisdiction_provider_grants row names it. Every jurisdiction keeps routing every capability group exactly as D94/D95/D98 left it (conduit baseline; sumsub primary for GB/EU Onboarding; utila primary for Custody), so behaviour is unchanged on deploy. The exact grant lines that turn Bridge on are recorded below under Go-live — the same assignIn(...) one-liner-per-group pattern D95 (Sumsub, Onboarding) and D98 (Utila, Custody) used, extended to four groups at once since Bridge serves four.
  6. D118 supersedes the D95 and D98 PRIMARY choices as the intended go-live target — adapters retained, parked. The owner decision behind this ADR is a single-vendor Bridge operating model: once granted, Bridge becomes primary for Onboarding, FiatRails, Conversion, and Custody, superseding Sumsub's D95 Onboarding-primary status and Utila's D98 Custody-primary status. This is a supersession of intent, not of code — neither D95 nor D98 is reversed or rewritten: their grants stay live exactly as shipped (sumsub primary GB/EU Onboarding, utila primary Custody all six) until the Bridge go-live grant below actually lands, and both adapters remain registered in the seam (parked, not removed) as permitted fallback keys after the flip — see Go-live. Fraud is untouched (D102's Fingerprint/SEON grant stands; Bridge has no AssessesFraudRisk capability to supersede it with). The decision log index rows for D95 and D98 are annotated accordingly.
  7. RequiresProvider now covers the remaining fiat-rails/conversion/funding makers. The multi-provider-development skill and docs/tracking/multi-provider/00-provider-router.md §4.1 have named "wiring RequiresProvider onto the remaining provider-sensitive commands" as a hard prerequisite before the next committed vendor since increment 2.1d-2b. This increment satisfies it for the groups Bridge would serve: Payments\Commands\CreateOrderCommand + CancelOrderCommand now carry RequiresProviderCapabilityGroup::Conversion; the payout maker command(s) and AddWhitelistRecipientCommandFiatRails; Funding\Commands\ SubmitSenderInformationCommandFiatRails. With conduit granted for all six baseline jurisdictions this is a no-op today (the bind-time resolver already resolves conduit everywhere); it makes the bus fail closed the day any of these groups routes to a second provider, rather than fail-SAFE→default. The onboarding + custody markers already existed (2.1d, 2.2e); Fraud is off-umbrella and out of scope here.
  8. Policy (user, 2026-09-03): Conduit is legacy code only. It must never again be a primary, a permitted fallback key, or the code-level default. The grant-flip PR (Go-live, below) removes Conduit from every grant and flips RailsServiceProvider::DEFAULT_PROVIDER to bridge; removing the Conduit adapter/SDK code itself is a later programme item, out of scope for both this increment and the grant-flip PR (see Follow-ups). This supersedes the D94/D95/D98 pattern of listing conduit as a universal override-permitted fallback key — that pattern was correct while Conduit was the incumbent baseline; it is not the target state.
  9. Groups no vendor serves get no grant row, not a legacy placeholder. Where the go-live grant flip leaves a (jurisdiction × CapabilityGroup) tuple with no vendor that serves it, the flip writes no row for that tuple rather than defaulting it to conduit (the current registerConduitBaseline placeholder behaviour). The RequiresProvider bus gate then fails the request closed (ProviderNotRoutableException) instead of silently landing it on a retired vendor — fail-closed, compliance-first, consistent with golden rule 6 in the multi-provider-development skill.
  10. Sovera is the designated fallback where Bridge does not support the activity type or jurisdiction and Sovera does. Two prerequisites gate this, both sequenced right after the grant-flip PR and each carrying its own ADR: S1 — a sealed PARTIAL Sovera rails adapter over sovera-sdk, registered and then added as a permitted key per jurisdiction where Sovera is licensed; S2 — activity-aware fallback routing that reuses the existing per-tenant tenant_provider_overrides mechanism, driven by the D117 activity-declaration evaluator: when a tenant's declared activities are prohibited/unknown on Bridge but supported on Sovera, an override pins sovera for the affected capability groups (only among that jurisdiction's permitted keys — compliance beats commerce). This is not a new routing axis — the router still resolves on (CapabilityGroup × jurisdiction) plus the existing override mechanism; D117's matrix already carries the per-provider activity-support data S2 reads.

Go-live (NOT shipped in this increment — human-approved; ships as its own, later PR)

The grant flip is a separate PR, and it lands only after a domain-alignment PR, not alongside it. Two increments still stand between this adapter and the grant above: (1) a domain alignment PRPayoutSubmitter and the order makers supplying the explicit source this adapter requires, Bridge webhook domain handlers actually writing the onboarding/payments/funding mirrors, and provider_customers rows being populated for Bridge — then (2) the grant-flip PR itself, which is nothing but the four assignIn lines below + features:sync + its own tests (one denied-routing test per group, mirroring 2.2e). This is a decision, not a menu of preconditions to satisfy in any order: the grant does not ship until domain alignment has shipped and merged first, mirroring how Utila's webhooks (2.2d) landed as their own increment ahead of, and separate from, the 2.2f go-live grant.

Once product/compliance sign off and domain alignment has shipped, committing Bridge as the sole primary is these four lines (the D95/D98 assignIn pattern, one per capability group Bridge serves) in Modules\Features\Providers\FeaturesServiceProvider::registerFeatureDeclarations(), replacing registerConduitBaseline(...) and the existing D95/D98 declarations rather than layering on top of them:

// replaces registerConduitBaseline(...) — Conduit appears in NO grant; parked vendors (utila,
// sumsub) are not permitted keys either (parked = registered, ungranted). Groups no vendor serves
// get NO row, so the RequiresProvider bus gate fails closed instead of landing on a legacy vendor.
$providers->assignIn(['ZA','NG','KE','GB','EU','US'], CapabilityGroup::Onboarding, ['bridge'], 'bridge');
$providers->assignIn(['ZA','NG','KE','GB','EU','US'], CapabilityGroup::FiatRails, ['bridge'], 'bridge');
$providers->assignIn(['ZA','NG','KE','GB','EU','US'], CapabilityGroup::Conversion, ['bridge'], 'bridge');
$providers->assignIn(['ZA','NG','KE','GB','EU','US'], CapabilityGroup::Custody, ['bridge'], 'bridge');
// Fraud line (fingerprint primary, seon permitted) unchanged.

features:sync is no-prune — the four lines above are not sufficient by themselves (capstone finding, 2026-09-03). Modules\Features\Console\Commands\SyncFeatures::syncJurisdictionProviderGrants() upserts declared grants and, on a primary flip, demotes the superseded primary to a permitted key — it never deletes a realized row (see the method's own NO-PRUNE docblock). Run the four assignIn lines through features:sync alone and every prior conduit/sumsub/utila grant row survives as a permitted key, and any group Bridge does not end up serving keeps its stale conduit PRIMARY — both violate the Conduit-legacy policy below. The grant PR must therefore also ship one of: (a) features:sync gaining a declarative reconcile-delete for provider grants (rows no longer declared in code are removed — preferred, code stays the single source of truth), or (b) an explicit features:revoke-provider-grants step run before the flip. Which of the two is a decision for the grant PR's own ADR to make and record — full analysis in docs/superpowers/specs/2026-09-03-bridge-rails-adapter-design.md §10.

then php artisan features:sync (having applied the prune/revoke step above), plus RailsServiceProvider::DEFAULT_PROVIDER = 'bridge' (the bind-time fail-safe for a missing grant) and the test fixtures that relied on the implicit Conduit default (features:sync in setUp() is the pattern). Policy (user, 2026-09-03): Conduit is legacy code only — it must never again be a primary, a permitted fallback key, or the code-level default; this grant flip is where that policy becomes enforced in config, not merely stated intent. Removing the Conduit adapter/SDK code itself is a later programme item, not part of this flip (see Follow-ups). sumsub and utila are not listed as permitted keys either — parked means registered-and-ungranted, not fallback-eligible; a per-tenant tenant_provider_overrides row can still pin either adapter explicitly (the override mechanism itself is unchanged), but neither rides along as a jurisdiction-level fallback the way the D95/D98 precedent listed conduit. Groups no vendor serves at all get no grant row (no legacy placeholder), so RequiresProvider fails the bus request closed rather than routing it to a retired vendor. Sovera enters the permitted list per jurisdiction only once its own adapter exists (S1) and only where Bridge does not support the activity type or jurisdiction and Sovera does (S2) — see Follow-ups; neither is part of this grant-flip PR. Preconditions, per group: Onboarding — the D117 Customers-API payload mapping (or product acceptance of the KYC-links flow as final) and provider_customers rows written by Bridge customer.* webhook handlers (not built in this increment); FiatRails / ConversionPayoutSubmitter and the order makers supply the explicit source this adapter now requires, and Bridge transfer.* / virtual_account.activity webhook handlers exist in payments/funding; Custody — Bridge custodial wallets require Bridge Legal & Compliance approval and carry geo exclusions (https://apidocs.bridge.xyz/platform/wallets/overview.md, docs as of 2026-09-03) that must be cleared before any wallet-creation traffic; the custody-controls approval-quorum engine (D97) is confirmed as the sole N-eyes gate (no vendor co-sign to layer under, unlike Utila's native vote surface); Bridge custody webhook/mirror handlers exist (none built in this increment — Custody inherits the same "registered ingress, no domain handlers" posture as the other three groups). All four: the features:sync prune/revoke decision above has been made and implemented — the flip does not ship on the four assignIn lines + a bare features:sync alone; data_region shipped null (the D95/D98 precedent — the EU bloc jurisdiction has region = null, so a non-null data_region would residency_conflict and refuse EU routing outright); a production BRIDGE_API_KEY and a webhook endpoint registered with Bridge, its PEM public key held in SSM.

Alternatives rejected

  • A full-umbrella adapter with always-throwing stubs for the unserved interfaces. Rejected — violates the ISP convention every other adapter in the seam follows (Sumsub, Utila): a provider advertises exactly the interfaces it serves, so an unrouted capability fails loud at the bind (AdapterMissingCapabilityException, a wiring fault) rather than at call time against a stub that always throws (indistinguishable from a real outage in logs/metrics).
  • Keeping Utila as the intended Custody primary (rejected for now, not permanently closed). Utila's write path shipped in full (vault provisioning, per-vault SA secrets, Stage-0 approval quorum, the real forward) and its D98 grant remains live and unchanged by this ADR. The owner decision is nonetheless to move the intended Custody primary to Bridge once granted, in favour of a single-vendor operating model (one relationship, one set of KYC/compliance surfaces, one webhook/mirror shape to maintain long-term) over running two custody vendors concurrently for no product-visible benefit. Utila's adapter is retained, not removed — registered and parked (not a permitted fallback key in the Go-live grant; see item 9/10 and Go-live) — and this decision can be revisited if Bridge custody proves short of Utila's write-path maturity (Bridge has no native API-vote surface — see item 1a) before the grant actually flips.
  • Building the payments/funding/onboarding domain handlers in the same PR. The adapter and webhook ingress are additive and registered-not-granted; wiring real mirror-write handlers for Bridge events before any grant exists would be dead code exercised by nothing. Deferred to the go-live increment, mirroring how Utila's webhooks (2.2d) landed ahead of but close to its go-live grant (2.2f).
  • The Customers-API KYB path now. The fuller POST /v0/customers payload (full KYC/KYB fields, signed_agreement_id) is the eventual target for D117's pinned enums, but building that mapping without a live grant to exercise it against would be speculative; the KYC-Links path keeps parity with the Sumsub hosted-flow pattern and unblocks registration now.
  • (B) Per-group adapter classes, to fix the getTransaction/listTransactions ISP collision (item 1b) without a rename. One class per (provider, CapabilityGroup) instead of one class per provider would sidestep any cross-group method-name collision entirely — a Bridge-Onboarding class, a Bridge-FiatRails class, etc., each satisfying only its own group's interfaces. Rejected: it breaks the seam's ProviderAdapterRegistry "one provider key → one adapter class" registry model for every existing provider (Conduit, Sumsub, Utila, Fingerprint, SEON), a load-bearing redesign to fix one naming collision that a rename solves in a mechanical, narrowly-scoped sweep.
  • (C) Dropping the fiat transaction reads from Bridge's scope, i.e. not implementing ManagesTransactions at all and leaving fiat-rails transaction lookups to Conduit even under a Bridge grant. Rejected: it would leave a Bridge-primary FiatRails jurisdiction unable to read its own transaction history through the routed provider — a capability gap with no technical justification (Bridge's transfer object serves the read fine) purely to dodge a naming collision that the rename in item 1b fixes properly instead.
  • Conduit as a permitted fallback key in the go-live grant (the D95/D98 pattern, e.g. ['bridge','conduit']). Rejected per the Conduit-legacy policy (item 9): Conduit must never again be a primary, a permitted fallback, or the code-level default, so the go-live grant lists bridge alone in every group it serves rather than carrying Conduit along as a safety net.
  • Legacy placeholder grants for groups no vendor serves. Retaining registerConduitBaseline-style rows (defaulting an unserved group to conduit) was rejected in favour of writing no grant row at all for such groups (item 10): a missing row makes RequiresProvider fail the bus request closed, which is the correct compliance posture for a group with no licensed vendor — routing it to a retired vendor by default would look "working" while being wrong.

Consequences

  • Bridge becomes vendor-logged (vendor_request_logs, provider='bridge') and translatable (BridgeExceptionTranslator) from this increment forward, even though it serves zero live traffic — the same "known before adapted" posture Utila's 2.2a foundation step established.
  • The provider-landscape wiki's Bridge row moves from DORMANT SDK (zero app-modules/* imports) to REGISTERED, not granted — a new third state alongside DORMANT and ROUTED that future vendor entries (Sovera, sFOX) will also pass through on their way to a grant.
  • rails.sensitive_fields gains Bridge's KYC/bank keys (tax id, SSN, birth date, account/routing numbers, IBAN/BIC, CLABE, Pix key, document number, developer_reason, kyc_link, tos_link, signed_agreement_id) — Bridge error messages can echo submitted KYC values back, so these ride as non-rendered diagnostics only, never in a rendered client error.
  • RequiresProvider now covers the full set of provider-sensitive commands the multi-provider program's hard-prerequisite list named (onboarding, custody, and now fiat-rails/conversion/ funding) — the fail-SAFE→default bind-time resolver is no longer the only thing standing between a mis-routed grant and a compliance gap on these paths.
  • ManagesCustodyTransactions::getTransaction/listTransactions are renamed to getCustodyTransaction/listCustodyTransactions (item 1b) — a breaking rename for every existing implementer and caller, swept mechanically across Utila\UtilaRailsProvider, the custody-controls consumer(s), and accounts, plus their tests. ManagesTransactions (FiatRails) is untouched. This is the seam's first instance of a naming collision surfacing only once a multi-group adapter existed to expose it — the multi-provider-development skill now states the general rule (segregated capability interfaces must use group-distinct method names) so the next multi-group adapter doesn't rediscover the same class of defect the hard way.
  • D95 and D98 are not reversed. Both grants remain live and unchanged — sumsub stays the Onboarding primary for GB/EU, utila stays the Custody primary for all six, until the Go-live grant above actually lands. This ADR records the revised intent only; the decision log index rows for D95 and D98 carry a pointer note to this ADR so a reader lands on the current target state, not just the historical one.

Accepted risks (carried forward, tracked)

  • Relationship/evidence documents are not transmitted to Bridge on whitelist recipients. relationship and evidenceDocumentIds have no Bridge external-account field — they are retained locally by the domain (not lost from our side) but never reach Bridge. Not a regression from Conduit parity assessment, but worth stating plainly per the D98 "recorded honestly, not silently omitted" precedent.
  • Documents are not forwarded on submitApplication. Bridge's hosted KYC/KYB flow collects them directly (Persona-backed); a caller-supplied documents array on the submit payload is silently dropped rather than transmitted — this is by design (see Decision item 2), not an oversight, but it means a domain caller that assumes document-forward parity with the Conduit adapter will be surprised.
  • No inbound-deposit RFI. submitSenderInformation throws ProviderOperationNotSupportedException — Bridge has no analogue of Conduit's deposit-held RFI flow; travel-rule originator data on a Bridge-routed transfer goes out via a separate /travel_rule surface Bridge exposes, not modelled as a capability in this increment (see Follow-ups).
  • No vendor-side custody co-sign, at all, ever. Unlike the Conduit→Utila transition (D96/D98, where the hosted co-sign ceremony was replaced by Utila's own API-vote model), Bridge has no approval/vote surface of any kind for custodial-wallet transfers. Once Custody is granted to Bridge, the in-app custody-controls approval-quorum engine (D97) becomes the entire N-eyes control for a custody movement — there is no vendor-side second layer to fall back on if that engine has a gap. This raises the bar on the correctness of the in-app engine for Bridge-routed custody in a way it did not for Utila (which still exposes a native vote to reconcile against).
  • Bridge custodial wallets require Legal & Compliance approval and carry geo exclusions (https://apidocs.bridge.xyz/platform/wallets/overview.md, docs as of 2026-09-03) — an operational gate this ADR does not resolve; it is a named Go-live precondition, not a code guarantee.

Follow-ups (tracked)

Immediate (domain alignment, precedes the Go-live PR — see Go-live above):

  • Bridge webhook domain handlers (onboarding/payments/funding mirror writers) — the webhook ingress is registered but produces no mirror write today.
  • Domain payload alignment: PayoutSubmitter and the order makers supplying the explicit source Bridge's transfer object requires.
  • provider_customers rows populated for Bridge (written by the domain handlers above).
  • Customers-API KYB path carrying D117's full activity-declaration payload, once product accepts moving off the KYC-Links hosted flow.
  • Decide + implement the features:sync provider-grant prune (reconcile-delete) or an explicit features:revoke-provider-grants step (see Go-live above) — the four-line grant flip is incomplete without it: features:sync is no-prune, so it alone would leave every prior conduit/sumsub/utila grant row as a permitted key and any unserved group's conduit row PRIMARY.
  • Boot-time validation of the configured Bridge webhook PEM public key(s): openssl_verify() emits a PHP E_WARNING per delivery per malformed key in BridgeSignatureValidator (fail-closed, not exploitable — deferred review nit); validate webhook-client.configs[bridge].signing_secret PEMs once at boot/config-cache time instead of per request.

The Bridge coverage programme — "anything and everything Bridge offers, we offer" (user decision, 2026-09-03). Every bridge-sdk resource, classified against the rails seam: A (served by this PR — maps onto an existing capability interface), B (a genuinely new capability class — new segregated interface + neutral DTOs + a consuming mirror-domain module + client API, one increment each), C (operator/plumbing, not customer-facing). Full rationale + sequencing: docs/superpowers/specs/2026-09-03-bridge-rails-adapter-design.md §12; the live tracker (kept current as increments ship) is docs/tracking/multi-provider/00-provider-router.md.

SDK resourceClassIncrement / home
customers, kycLinks, associatedPersonsAthis PR — Onboarding
virtualAccountsAthis PR — FiatRails
externalAccountsAthis PR — FiatRails (whitelist recipients)
transfersAthis PR — FiatRails + Conversion
walletsAthis PR — Custody
liquidationAddresses (+ drains)BB1 — new ManagesLiquidationAddresses capability (crypto off-ramp intake)
travelRuleBB2 — new SubmitsTravelRule on CapabilityGroup::TravelRule; closes the submitSenderInformation gap
exchangeRatesBB3 — Bridge-backed ExchangeRateProvider in treasury (Conversion)
staticMemosBB4 — extends virtual-account intake (FiatRails)
cardsBB5 — new IssuesCards capability + a new cards module (largest item, own design)
plaidBB6 — extension of whitelist-recipient registration (FiatRails)
prefundedAccountsB/CB7 — treasury read-only operator view
fundsRequestsBB8 — payments (returns/recalls lifecycle)
cryptoReturnPoliciesBB9 — payments/compliance config surface
rewardsBB10 — treasury/reporting, lowest priority
batchSettlementsCoperator config (backoffice)
developers, fiatPayoutConfiguration, apiKeys, webhooks (mgmt), sandbox, listsCops/config — lists already consumed by the D117 catalog snapshots

Sequencing: B1–B3 complete the money-movement + compliance story a Bridge-exclusive shop needs first; B4–B6 widen intake; B5 (cards) is its own programme. Each B item ships as its own PR with its own ADR, following the "adding a capability" checklist in the multi-provider-development skill. The Go-live grant flip (above) lands after the domain-alignment PR and before B1.

Sovera fallback (item 11) — S1/S2, sequenced right after the grant-flip PR:

ItemScope
S1 — Sovera rails adapterSealed PARTIAL adapter over sovera-sdk (crypto + cards, transport-only; business end-user onboarding still a vendor doc gap). Own ADR. Registered, then added as a permitted key per jurisdiction where Sovera is licensed.
S2 — activity-aware fallback routingReuses the existing per-tenant tenant_provider_overrides mechanism, driven by the D117 activity-declaration evaluator: when a tenant's declared activities are prohibited/unknown on Bridge but supported on Sovera, write an override pinning sovera for the affected groups (only among the jurisdiction's permitted keys). No new routing axis. Own ADR.

Other tracked items (unaffected by this ADR):

  • Conduit-vocabulary removal (ConduitRailsProvider::PROVIDER literals, ~75 refs).
  • Conduit code removal — deleting the ConduitRailsProvider adapter and conduit-sdk entirely, once nothing routes to it. A later programme item, out of scope for both this increment and the grant-flip PR (item 9) — the grant flip removes Conduit from grants, not from the codebase.
  • A Scramble enum $ref fix for D117's verdict/verdict_source fields (a core-web request, unrelated to Bridge but raised alongside this increment).

This decision builds on the router substrate (D94), the first non-Conduit grant precedent (D95), the second worked go-live instance (D98), and the onboarding activity declaration that first flagged the missing Bridge adapter (D117); it does not restate them.


← Decision log index